Config screen
This commit is contained in:
parent
ac88e62abe
commit
4b3c512a9d
28 changed files with 2015 additions and 59 deletions
|
|
@ -4,4 +4,5 @@ plugins {
|
|||
alias(libs.plugins.composeMultiplatform) apply false
|
||||
alias(libs.plugins.composeCompiler) apply false
|
||||
alias(libs.plugins.kotlinMultiplatform) apply false
|
||||
alias(libs.plugins.kotlinSerialization) apply false
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ plugins {
|
|||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
|
@ -23,6 +24,9 @@ kotlin {
|
|||
jvmMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutinesSwing)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlin.logging.jvm)
|
||||
implementation(libs.logback.classic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +35,8 @@ compose.desktop {
|
|||
application {
|
||||
mainClass = "com.rukira.wowbackup.MainKt"
|
||||
|
||||
jvmArgs("-Dapple.awt.enableTemplateImages=true")
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "com.rukira.wowbackup"
|
||||
|
|
|
|||
|
|
@ -1,49 +1,60 @@
|
|||
package com.rukira.wowbackup
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.safeContentPadding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||
|
||||
import myapplication.composeapp.generated.resources.Res
|
||||
import myapplication.composeapp.generated.resources.compose_multiplatform
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rukira.wowbackup.ui.Screen
|
||||
import com.rukira.wowbackup.ui.config.ConfigScreen
|
||||
import com.rukira.wowbackup.ui.config.ConfigViewModel
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
fun App(
|
||||
currentScreen: Screen,
|
||||
onNavigate: (Screen) -> Unit,
|
||||
) {
|
||||
MaterialTheme {
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
when (currentScreen) {
|
||||
Screen.STATUS -> PlaceholderScreen("Status", "Backup status will appear here.")
|
||||
Screen.CONFIG -> {
|
||||
val viewModel = remember { ConfigViewModel() }
|
||||
ConfigScreen(
|
||||
viewModel = viewModel,
|
||||
onSaved = { onNavigate(Screen.STATUS) },
|
||||
onCancel = { onNavigate(Screen.STATUS) },
|
||||
)
|
||||
}
|
||||
Screen.RESTORE -> PlaceholderScreen("Restore", "Restore from backup will appear here.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderScreen(title: String, subtitle: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.safeContentPadding()
|
||||
.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Button(onClick = { showContent = !showContent }) {
|
||||
Text("Click me!")
|
||||
}
|
||||
AnimatedVisibility(showContent) {
|
||||
val greeting = remember { Greeting().greet() }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(painterResource(Res.drawable.compose_multiplatform), null)
|
||||
Text("Compose: $greeting")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
package com.rukira.wowbackup
|
||||
|
||||
class Greeting {
|
||||
private val platform = getPlatform()
|
||||
|
||||
fun greet(): String {
|
||||
return "Hello, ${platform.name}!"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package com.rukira.wowbackup
|
||||
|
||||
class JVMPlatform {
|
||||
val name: String = "Java ${System.getProperty("java.version")}"
|
||||
}
|
||||
|
||||
fun getPlatform() = JVMPlatform()
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.rukira.wowbackup.config
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import java.time.LocalTime
|
||||
|
||||
object LocalTimeSerializer : KSerializer<LocalTime> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("LocalTime", PrimitiveKind.STRING)
|
||||
override fun serialize(encoder: Encoder, value: LocalTime) = encoder.encodeString(value.toString())
|
||||
override fun deserialize(decoder: Decoder): LocalTime = LocalTime.parse(decoder.decodeString())
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AppConfig(
|
||||
val wowInstallPath: String? = null,
|
||||
val backupPath: String? = null,
|
||||
@Serializable(with = LocalTimeSerializer::class)
|
||||
val backupTimeOfDay: LocalTime = LocalTime.of(3, 0),
|
||||
val backupHistoryCount: Int = 5,
|
||||
val forceBackupWhileRunning: Boolean = false,
|
||||
val backupWtf: Boolean = true,
|
||||
val backupInterface: Boolean = true,
|
||||
val compressionEnabled: Boolean = false,
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val runAtStartup: Boolean = false,
|
||||
) {
|
||||
val isConfigured: Boolean
|
||||
get() = !wowInstallPath.isNullOrBlank() && !backupPath.isNullOrBlank()
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.rukira.wowbackup.config
|
||||
|
||||
import com.rukira.wowbackup.platform.AppDirectories
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
object ConfigManager {
|
||||
|
||||
private val _config = MutableStateFlow(AppConfig())
|
||||
val config: StateFlow<AppConfig> = _config.asStateFlow()
|
||||
|
||||
val isConfigured: Boolean
|
||||
get() = _config.value.isConfigured
|
||||
|
||||
fun load() {
|
||||
val file = AppDirectories.configFile
|
||||
if (file.exists()) {
|
||||
try {
|
||||
val text = file.readText()
|
||||
_config.value = json.decodeFromString<AppConfig>(text)
|
||||
logger.info { "Config loaded from $file" }
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to load config from $file, using defaults" }
|
||||
_config.value = AppConfig()
|
||||
}
|
||||
} else {
|
||||
logger.info { "No config file found, using defaults" }
|
||||
_config.value = AppConfig()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
fun save(config: AppConfig = _config.value) {
|
||||
_config.value = config
|
||||
val file = AppDirectories.configFile
|
||||
try {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(json.encodeToString(AppConfig.serializer(), config))
|
||||
logger.info { "Config saved to $file" }
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to save config to $file" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.rukira.wowbackup.logging
|
||||
|
||||
import ch.qos.logback.classic.LoggerContext
|
||||
import ch.qos.logback.classic.encoder.PatternLayoutEncoder
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent
|
||||
import ch.qos.logback.core.rolling.RollingFileAppender
|
||||
import ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy
|
||||
import ch.qos.logback.core.util.FileSize
|
||||
import com.rukira.wowbackup.platform.AppDirectories
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.File
|
||||
|
||||
object LoggingSetup {
|
||||
|
||||
fun init() {
|
||||
val logDir = AppDirectories.logsDir
|
||||
val logFile = File(logDir, "wowbackup.log")
|
||||
|
||||
val context = LoggerFactory.getILoggerFactory() as LoggerContext
|
||||
|
||||
val encoder = PatternLayoutEncoder().apply {
|
||||
this.context = context
|
||||
pattern = "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
start()
|
||||
}
|
||||
|
||||
val rollingPolicy = SizeAndTimeBasedRollingPolicy<ILoggingEvent>().apply {
|
||||
this.context = context
|
||||
fileNamePattern = "${logDir}/wowbackup.%d{yyyy-MM-dd}.%i.log.gz"
|
||||
setMaxFileSize(FileSize.valueOf("10MB"))
|
||||
maxHistory = 30
|
||||
setTotalSizeCap(FileSize.valueOf("100MB"))
|
||||
}
|
||||
|
||||
val fileAppender = RollingFileAppender<ILoggingEvent>().apply {
|
||||
this.context = context
|
||||
name = "FILE"
|
||||
file = logFile.absolutePath
|
||||
this.encoder = encoder
|
||||
this.rollingPolicy = rollingPolicy
|
||||
}
|
||||
|
||||
rollingPolicy.setParent(fileAppender)
|
||||
rollingPolicy.start()
|
||||
fileAppender.start()
|
||||
|
||||
val rootLogger = context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)
|
||||
rootLogger.addAppender(fileAppender)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,67 @@
|
|||
package com.rukira.wowbackup
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.window.Tray
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import com.rukira.wowbackup.config.ConfigManager
|
||||
import com.rukira.wowbackup.logging.LoggingSetup
|
||||
import com.rukira.wowbackup.platform.WoWLocations
|
||||
import com.rukira.wowbackup.ui.Screen
|
||||
import com.rukira.wowbackup.ui.trayIconPainter
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
|
||||
fun main() = application {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
fun main() {
|
||||
LoggingSetup.init()
|
||||
logger.info { "WoW Backup starting" }
|
||||
|
||||
ConfigManager.load()
|
||||
logger.info { "Config loaded. Configured: ${ConfigManager.isConfigured}" }
|
||||
|
||||
val detectedWoW = WoWLocations.findWoWInstall()
|
||||
if (detectedWoW != null) {
|
||||
logger.info { "Auto-detected WoW at: $detectedWoW" }
|
||||
}
|
||||
|
||||
val startScreen = if (ConfigManager.isConfigured) Screen.STATUS else Screen.CONFIG
|
||||
|
||||
application {
|
||||
var isWindowVisible by remember { mutableStateOf(true) }
|
||||
var currentScreen by remember { mutableStateOf(startScreen) }
|
||||
|
||||
Tray(
|
||||
icon = trayIconPainter(),
|
||||
tooltip = "WoW Backup",
|
||||
onAction = { isWindowVisible = true },
|
||||
menu = {
|
||||
Item("Status", onClick = {
|
||||
currentScreen = Screen.STATUS
|
||||
isWindowVisible = true
|
||||
})
|
||||
Item("Settings", onClick = {
|
||||
currentScreen = Screen.CONFIG
|
||||
isWindowVisible = true
|
||||
})
|
||||
Separator()
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
},
|
||||
)
|
||||
|
||||
if (isWindowVisible) {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "MyApplication",
|
||||
onCloseRequest = { isWindowVisible = false },
|
||||
title = "WoW Backup",
|
||||
) {
|
||||
App()
|
||||
App(
|
||||
currentScreen = currentScreen,
|
||||
onNavigate = { currentScreen = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.rukira.wowbackup.platform
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import java.io.File
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
object AppDirectories {
|
||||
|
||||
private const val APP_NAME = "WoWBackup"
|
||||
|
||||
val appDataDir: File by lazy {
|
||||
val dir = resolveAppDataDir()
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
logger.info { "Created app data directory: $dir" }
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
val logsDir: File by lazy {
|
||||
val dir = File(appDataDir, "logs")
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
val configFile: File
|
||||
get() = File(appDataDir, "config.json")
|
||||
|
||||
private fun resolveAppDataDir(): File {
|
||||
val home = System.getProperty("user.home")
|
||||
return when (Platform.current) {
|
||||
OS.Mac -> File(home, "Library/Application Support/$APP_NAME")
|
||||
OS.Windows -> {
|
||||
val appData = System.getenv("APPDATA") ?: "$home/AppData/Roaming"
|
||||
File(appData, APP_NAME)
|
||||
}
|
||||
OS.Linux -> {
|
||||
val xdgConfig = System.getenv("XDG_CONFIG_HOME") ?: "$home/.config"
|
||||
File(xdgConfig, APP_NAME)
|
||||
}
|
||||
OS.Other -> File(home, ".$APP_NAME")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.rukira.wowbackup.platform
|
||||
|
||||
enum class OS { Windows, Mac, Linux, Other }
|
||||
|
||||
object Platform {
|
||||
val current: OS = System.getProperty("os.name").lowercase().let { name ->
|
||||
when {
|
||||
"mac" in name -> OS.Mac
|
||||
"win" in name -> OS.Windows
|
||||
"nux" in name || "nix" in name -> OS.Linux
|
||||
else -> OS.Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package com.rukira.wowbackup.platform
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import java.io.File
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
object WoWLocations {
|
||||
|
||||
fun findWoWInstall(): File? {
|
||||
val candidates = when (Platform.current) {
|
||||
OS.Mac -> listOf(
|
||||
File("/Applications/World of Warcraft"),
|
||||
File(System.getProperty("user.home"), "Applications/World of Warcraft"),
|
||||
)
|
||||
OS.Windows -> buildList {
|
||||
add(File("C:\\Program Files (x86)\\World of Warcraft"))
|
||||
add(File("C:\\Program Files\\World of Warcraft"))
|
||||
System.getenv("PROGRAMFILES(X86)")?.let {
|
||||
add(File(it, "World of Warcraft"))
|
||||
}
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
for (candidate in candidates) {
|
||||
if (candidate.exists() && isValidWoWInstall(candidate)) {
|
||||
logger.info { "Found WoW installation at: $candidate" }
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
logger.info { "No WoW installation found in default locations" }
|
||||
return null
|
||||
}
|
||||
|
||||
fun isValidWoWInstall(dir: File): Boolean {
|
||||
return validateWoWInstall(dir).isValid
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed validation of a WoW install directory.
|
||||
*
|
||||
* WoW's directory structure is typically:
|
||||
* World of Warcraft/
|
||||
* _retail_/
|
||||
* WTF/
|
||||
* Interface/
|
||||
* _classic_/
|
||||
* WTF/
|
||||
* Interface/
|
||||
*
|
||||
* If the user selects the root "World of Warcraft" folder (containing _retail_ or _classic_),
|
||||
* we resolve down to _retail_ automatically. If they select _retail_ or _classic_ directly,
|
||||
* we validate that folder as-is.
|
||||
*
|
||||
* [resolvedDir] in the result contains the actual game data directory to use.
|
||||
*/
|
||||
fun validateWoWInstall(dir: File): WoWValidationResult {
|
||||
if (!dir.isDirectory) {
|
||||
return WoWValidationResult(isValid = false, error = "Not a directory.")
|
||||
}
|
||||
|
||||
val contents = dir.listFiles()
|
||||
if (contents == null) {
|
||||
return WoWValidationResult(
|
||||
isValid = false,
|
||||
error = "Cannot read directory. Check folder permissions.",
|
||||
)
|
||||
}
|
||||
|
||||
// If selected dir contains _retail_ or _classic_, resolve into the game data subfolder
|
||||
val retailDir = contents.find { it.name.equals("_retail_", ignoreCase = true) && it.isDirectory }
|
||||
val classicDir = contents.find { it.name.equals("_classic_", ignoreCase = true) && it.isDirectory }
|
||||
|
||||
if (retailDir != null || classicDir != null) {
|
||||
// This is the root WoW install folder — resolve to _retail_ (preferred) or _classic_
|
||||
val gameDataDir = retailDir ?: classicDir!!
|
||||
logger.info { "Root WoW folder selected, resolving to: ${gameDataDir.name}" }
|
||||
return validateGameDataDir(gameDataDir)
|
||||
}
|
||||
|
||||
// Otherwise, check if this is itself a game data dir (has WTF/Interface/executable)
|
||||
return validateGameDataDir(dir)
|
||||
}
|
||||
|
||||
private fun validateGameDataDir(dir: File): WoWValidationResult {
|
||||
val contents = dir.listFiles()
|
||||
if (contents == null) {
|
||||
return WoWValidationResult(
|
||||
isValid = false,
|
||||
error = "Cannot read directory. Check folder permissions.",
|
||||
)
|
||||
}
|
||||
|
||||
val hasWoWMarker = contents.any { file ->
|
||||
file.name.equals("WoW.exe", ignoreCase = true) ||
|
||||
file.name.equals("World of Warcraft.app", ignoreCase = true) ||
|
||||
file.name.equals("WoW.app", ignoreCase = true)
|
||||
}
|
||||
|
||||
val wtfDir = contents.find { it.name.equals("WTF", ignoreCase = true) && it.isDirectory }
|
||||
val interfaceDir = contents.find { it.name.equals("Interface", ignoreCase = true) && it.isDirectory }
|
||||
|
||||
if (!hasWoWMarker && wtfDir == null && interfaceDir == null) {
|
||||
return WoWValidationResult(
|
||||
isValid = false,
|
||||
error = "Does not appear to be a WoW installation. No WoW executable, WTF, or Interface folder found.",
|
||||
)
|
||||
}
|
||||
|
||||
val warnings = mutableListOf<String>()
|
||||
|
||||
if (wtfDir == null) {
|
||||
warnings.add("WTF folder not found — it may be created after first login.")
|
||||
} else if (!wtfDir.canRead()) {
|
||||
warnings.add("WTF folder exists but is not readable. Check folder permissions.")
|
||||
}
|
||||
|
||||
if (interfaceDir == null) {
|
||||
warnings.add("Interface folder not found — it may be created after installing addons.")
|
||||
} else if (!interfaceDir.canRead()) {
|
||||
warnings.add("Interface folder exists but is not readable. Check folder permissions.")
|
||||
}
|
||||
|
||||
return WoWValidationResult(
|
||||
isValid = true,
|
||||
resolvedDir = dir,
|
||||
hasWtf = wtfDir != null,
|
||||
wtfReadable = wtfDir?.canRead() ?: false,
|
||||
hasInterface = interfaceDir != null,
|
||||
interfaceReadable = interfaceDir?.canRead() ?: false,
|
||||
warnings = warnings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class WoWValidationResult(
|
||||
val isValid: Boolean,
|
||||
val error: String? = null,
|
||||
/** The actual game data directory to use (may differ from user's selection if resolved from root). */
|
||||
val resolvedDir: File? = null,
|
||||
val hasWtf: Boolean = false,
|
||||
val wtfReadable: Boolean = false,
|
||||
val hasInterface: Boolean = false,
|
||||
val interfaceReadable: Boolean = false,
|
||||
val warnings: List<String> = emptyList(),
|
||||
)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.rukira.wowbackup.ui
|
||||
|
||||
enum class Screen {
|
||||
STATUS,
|
||||
CONFIG,
|
||||
RESTORE,
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package com.rukira.wowbackup.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.geometry.Size
|
||||
|
||||
/**
|
||||
* A simple shield-shaped tray icon drawn in code.
|
||||
* Works with macOS template images (-Dapple.awt.enableTemplateImages=true)
|
||||
* which will automatically adapt to dark/light menu bar.
|
||||
*/
|
||||
fun trayIconPainter(): Painter = object : Painter() {
|
||||
override val intrinsicSize = Size(32f, 32f)
|
||||
|
||||
override fun DrawScope.onDraw() {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
|
||||
// Shield shape
|
||||
val shield = Path().apply {
|
||||
moveTo(w * 0.5f, h * 0.05f)
|
||||
lineTo(w * 0.9f, h * 0.2f)
|
||||
lineTo(w * 0.9f, h * 0.5f)
|
||||
cubicTo(w * 0.9f, h * 0.75f, w * 0.5f, h * 0.95f, w * 0.5f, h * 0.95f)
|
||||
cubicTo(w * 0.5f, h * 0.95f, w * 0.1f, h * 0.75f, w * 0.1f, h * 0.5f)
|
||||
lineTo(w * 0.1f, h * 0.2f)
|
||||
close()
|
||||
}
|
||||
drawPath(shield, Color.Black)
|
||||
|
||||
// Checkmark inside the shield
|
||||
val check = Path().apply {
|
||||
moveTo(w * 0.25f, h * 0.48f)
|
||||
lineTo(w * 0.42f, h * 0.65f)
|
||||
lineTo(w * 0.72f, h * 0.32f)
|
||||
}
|
||||
drawPath(
|
||||
check,
|
||||
Color.White,
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = w * 0.08f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.rukira.wowbackup.ui.components
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun ConfirmationDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
confirmLabel: String = "Confirm",
|
||||
cancelLabel: String = "Cancel",
|
||||
destructive: Boolean = false,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
colors = if (destructive) {
|
||||
ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
} else {
|
||||
ButtonDefaults.textButtonColors()
|
||||
},
|
||||
) {
|
||||
Text(confirmLabel)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(cancelLabel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.rukira.wowbackup.ui.components
|
||||
|
||||
import com.rukira.wowbackup.platform.OS
|
||||
import com.rukira.wowbackup.platform.Platform
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
/**
|
||||
* Opens a native folder-selection dialog and returns the selected directory, or null if cancelled.
|
||||
* On macOS uses AWT FileDialog for native look; on Windows/Linux uses JFileChooser.
|
||||
*/
|
||||
fun pickFolder(title: String = "Select Folder", initialDir: File? = null): File? {
|
||||
var result: File? = null
|
||||
|
||||
val runnable = Runnable {
|
||||
result = when (Platform.current) {
|
||||
OS.Mac -> pickFolderMac(title, initialDir)
|
||||
else -> pickFolderSwing(title, initialDir)
|
||||
}
|
||||
}
|
||||
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
runnable.run()
|
||||
} else {
|
||||
SwingUtilities.invokeAndWait(runnable)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun pickFolderMac(title: String, initialDir: File?): File? {
|
||||
// Enable folder selection for AWT FileDialog on macOS
|
||||
val previous = System.getProperty("apple.awt.fileDialogForDirectories")
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", "true")
|
||||
|
||||
try {
|
||||
val dialog = FileDialog(null as Frame?, title, FileDialog.LOAD)
|
||||
if (initialDir != null) {
|
||||
dialog.directory = initialDir.absolutePath
|
||||
}
|
||||
dialog.isVisible = true
|
||||
|
||||
val dir = dialog.directory
|
||||
val file = dialog.file
|
||||
return if (dir != null && file != null) File(dir, file) else null
|
||||
} finally {
|
||||
if (previous != null) {
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", previous)
|
||||
} else {
|
||||
System.clearProperty("apple.awt.fileDialogForDirectories")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickFolderSwing(title: String, initialDir: File?): File? {
|
||||
val chooser = JFileChooser().apply {
|
||||
dialogTitle = title
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
isAcceptAllFileFilterUsed = false
|
||||
if (initialDir != null) {
|
||||
currentDirectory = initialDir
|
||||
}
|
||||
}
|
||||
|
||||
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||
chooser.selectedFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.rukira.wowbackup.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.LocalTime
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TimePicker(
|
||||
value: LocalTime,
|
||||
onValueChange: (LocalTime) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Hour dropdown
|
||||
TimeDropdown(
|
||||
label = "Hour",
|
||||
selectedValue = value.hour,
|
||||
options = (0..23).toList(),
|
||||
format = { "%02d".format(it) },
|
||||
onSelect = { onValueChange(value.withHour(it)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Text(":")
|
||||
|
||||
// Minute dropdown (15-min increments)
|
||||
TimeDropdown(
|
||||
label = "Minute",
|
||||
selectedValue = value.minute,
|
||||
options = listOf(0, 15, 30, 45),
|
||||
format = { "%02d".format(it) },
|
||||
onSelect = { onValueChange(value.withMinute(it)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TimeDropdown(
|
||||
label: String,
|
||||
selectedValue: Int,
|
||||
options: List<Int>,
|
||||
format: (Int) -> String,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = modifier,
|
||||
) {
|
||||
TextField(
|
||||
value = format(selectedValue),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(format(option)) },
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
package com.rukira.wowbackup.ui.config
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rukira.wowbackup.ui.components.ConfirmationDialog
|
||||
import com.rukira.wowbackup.ui.components.TimePicker
|
||||
import com.rukira.wowbackup.ui.components.pickFolder
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun ConfigScreen(
|
||||
viewModel: ConfigViewModel,
|
||||
onSaved: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val uiState by viewModel.state.collectAsState()
|
||||
val config = uiState.config
|
||||
val errors = uiState.errors
|
||||
var showForceBackupDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// === WoW Installation ===
|
||||
SectionHeader("WoW Installation")
|
||||
|
||||
PathField(
|
||||
label = "WoW install location",
|
||||
value = config.wowInstallPath ?: "",
|
||||
error = errors["wowPath"],
|
||||
onBrowse = {
|
||||
val dir = pickFolder("Select WoW Install Folder", config.wowInstallPath?.let { File(it) })
|
||||
if (dir != null) viewModel.updateWoWPath(dir.absolutePath)
|
||||
},
|
||||
)
|
||||
|
||||
// WoW path warnings (e.g. missing WTF/Interface, permission issues)
|
||||
uiState.warnings["wowPath"]?.forEach { warning ->
|
||||
Text(
|
||||
warning,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
// WoW validation summary when valid
|
||||
uiState.wowValidation?.let { validation ->
|
||||
if (validation.isValid) {
|
||||
val wtfStatus = when {
|
||||
validation.wtfReadable -> "WTF folder: found"
|
||||
validation.hasWtf -> "WTF folder: found (not readable)"
|
||||
else -> "WTF folder: not found"
|
||||
}
|
||||
val interfaceStatus = when {
|
||||
validation.interfaceReadable -> "Interface folder: found"
|
||||
validation.hasInterface -> "Interface folder: found (not readable)"
|
||||
else -> "Interface folder: not found"
|
||||
}
|
||||
Text(
|
||||
"$wtfStatus | $interfaceStatus",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(onClick = { viewModel.detectWoWLocation() }) {
|
||||
Text("Auto-detect")
|
||||
}
|
||||
|
||||
// === Backup Settings ===
|
||||
SectionHeader("Backup Settings")
|
||||
|
||||
PathField(
|
||||
label = "Backup destination",
|
||||
value = config.backupPath ?: "",
|
||||
error = errors["backupPath"],
|
||||
onBrowse = {
|
||||
val dir = pickFolder("Select Backup Destination", config.backupPath?.let { File(it) })
|
||||
if (dir != null) viewModel.updateBackupPath(dir.absolutePath)
|
||||
},
|
||||
)
|
||||
|
||||
Text("Daily backup time", style = MaterialTheme.typography.bodyMedium)
|
||||
TimePicker(
|
||||
value = config.backupTimeOfDay,
|
||||
onValueChange = { viewModel.updateBackupTime(it) },
|
||||
modifier = Modifier.width(240.dp),
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Backups to keep:", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
HistoryCountSelector(
|
||||
value = config.backupHistoryCount,
|
||||
onValueChange = { viewModel.updateBackupHistoryCount(it) },
|
||||
)
|
||||
}
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.forceBackupWhileRunning,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
showForceBackupDialog = true
|
||||
} else {
|
||||
viewModel.updateForceBackup(false)
|
||||
}
|
||||
},
|
||||
label = "Allow backup while WoW is running",
|
||||
)
|
||||
|
||||
// === Folders to Backup ===
|
||||
SectionHeader("Folders to Backup")
|
||||
|
||||
if (errors.containsKey("folders")) {
|
||||
Text(errors["folders"]!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.backupWtf,
|
||||
onCheckedChange = { viewModel.updateBackupWtf(it) },
|
||||
label = "WTF",
|
||||
description = "Contains account settings, keybinds, macros, and addon saved variables.",
|
||||
)
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.backupInterface,
|
||||
onCheckedChange = { viewModel.updateBackupInterface(it) },
|
||||
label = "Interface",
|
||||
description = "Contains installed addons and their configuration.",
|
||||
)
|
||||
|
||||
// === Options ===
|
||||
SectionHeader("Options")
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.compressionEnabled,
|
||||
onCheckedChange = { viewModel.updateCompression(it) },
|
||||
label = "Compression",
|
||||
description = "Compress backups to save disk space. Slightly slower backup/restore.",
|
||||
)
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.notificationsEnabled,
|
||||
onCheckedChange = { viewModel.updateNotifications(it) },
|
||||
label = "Notifications",
|
||||
description = "Show system notifications for backup events.",
|
||||
)
|
||||
|
||||
CheckboxRow(
|
||||
checked = config.runAtStartup,
|
||||
onCheckedChange = { viewModel.updateRunAtStartup(it) },
|
||||
label = "Run at startup",
|
||||
description = "Launch WoW Backup automatically when you log in.",
|
||||
)
|
||||
|
||||
// === Footer ===
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (uiState.saveSuccess) {
|
||||
Text(
|
||||
"Saved!",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
viewModel.resetToSaved()
|
||||
onCancel()
|
||||
}) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = {
|
||||
if (viewModel.save()) {
|
||||
onSaved()
|
||||
}
|
||||
}) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force backup confirmation dialog
|
||||
if (showForceBackupDialog) {
|
||||
ConfirmationDialog(
|
||||
title = "Allow backup while WoW is running?",
|
||||
message = "Backing up while WoW is running may capture an inconsistent state. " +
|
||||
"Saved variables and settings files may be partially written. " +
|
||||
"Only enable this if you understand the risk.",
|
||||
confirmLabel = "Enable",
|
||||
onConfirm = {
|
||||
viewModel.updateForceBackup(true)
|
||||
showForceBackupDialog = false
|
||||
},
|
||||
onDismiss = { showForceBackupDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Column {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
HorizontalDivider(modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PathField(
|
||||
label: String,
|
||||
value: String,
|
||||
error: String?,
|
||||
onBrowse: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label) },
|
||||
isError = error != null,
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = onBrowse) {
|
||||
Text("Browse")
|
||||
}
|
||||
}
|
||||
if (error != null) {
|
||||
Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckboxRow(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
label: String,
|
||||
description: String? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
if (description != null) {
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistoryCountSelector(
|
||||
value: Int,
|
||||
onValueChange: (Int) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { onValueChange(value - 1) },
|
||||
enabled = value > 1,
|
||||
) {
|
||||
Text("-")
|
||||
}
|
||||
Text(
|
||||
"$value",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.width(32.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { onValueChange(value + 1) },
|
||||
enabled = value < 30,
|
||||
) {
|
||||
Text("+")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package com.rukira.wowbackup.ui.config
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.rukira.wowbackup.config.AppConfig
|
||||
import com.rukira.wowbackup.config.ConfigManager
|
||||
import com.rukira.wowbackup.platform.WoWLocations
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import com.rukira.wowbackup.platform.WoWValidationResult
|
||||
import java.io.File
|
||||
import java.time.LocalTime
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
data class ConfigUiState(
|
||||
val config: AppConfig = AppConfig(),
|
||||
val errors: Map<String, String> = emptyMap(),
|
||||
val warnings: Map<String, List<String>> = emptyMap(),
|
||||
val saveSuccess: Boolean = false,
|
||||
val wowValidation: WoWValidationResult? = null,
|
||||
)
|
||||
|
||||
class ConfigViewModel : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(ConfigUiState(config = ConfigManager.config.value))
|
||||
val state: StateFlow<ConfigUiState> = _state.asStateFlow()
|
||||
|
||||
fun updateWoWPath(path: String) {
|
||||
val dir = File(path)
|
||||
val validation = if (dir.exists()) WoWLocations.validateWoWInstall(dir) else null
|
||||
|
||||
// Use the resolved path (e.g. root WoW folder resolves to _retail_)
|
||||
val resolvedPath = validation?.resolvedDir?.absolutePath ?: path
|
||||
|
||||
_state.update {
|
||||
val newErrors = it.errors.toMutableMap()
|
||||
val newWarnings = it.warnings.toMutableMap()
|
||||
|
||||
if (validation == null) {
|
||||
newErrors["wowPath"] = "Directory does not exist."
|
||||
newWarnings.remove("wowPath")
|
||||
} else if (!validation.isValid) {
|
||||
newErrors["wowPath"] = validation.error ?: "Not a valid WoW installation."
|
||||
newWarnings.remove("wowPath")
|
||||
} else {
|
||||
newErrors.remove("wowPath")
|
||||
if (validation.warnings.isNotEmpty()) {
|
||||
newWarnings["wowPath"] = validation.warnings
|
||||
} else {
|
||||
newWarnings.remove("wowPath")
|
||||
}
|
||||
}
|
||||
|
||||
it.copy(
|
||||
config = it.config.copy(wowInstallPath = resolvedPath),
|
||||
errors = newErrors,
|
||||
warnings = newWarnings,
|
||||
wowValidation = validation,
|
||||
saveSuccess = false,
|
||||
)
|
||||
}
|
||||
|
||||
if (resolvedPath != path) {
|
||||
logger.info { "WoW path resolved: $path -> $resolvedPath" }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackupPath(path: String) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
config = it.config.copy(backupPath = path),
|
||||
errors = it.errors - "backupPath",
|
||||
saveSuccess = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackupTime(time: LocalTime) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(backupTimeOfDay = time), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackupHistoryCount(count: Int) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(backupHistoryCount = count.coerceIn(1, 30)), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateForceBackup(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(forceBackupWhileRunning = enabled), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackupWtf(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
config = it.config.copy(backupWtf = enabled),
|
||||
errors = it.errors - "folders",
|
||||
saveSuccess = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackupInterface(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
config = it.config.copy(backupInterface = enabled),
|
||||
errors = it.errors - "folders",
|
||||
saveSuccess = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCompression(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(compressionEnabled = enabled), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotifications(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(notificationsEnabled = enabled), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRunAtStartup(enabled: Boolean) {
|
||||
_state.update {
|
||||
it.copy(config = it.config.copy(runAtStartup = enabled), saveSuccess = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun detectWoWLocation() {
|
||||
val detected = WoWLocations.findWoWInstall()
|
||||
if (detected != null) {
|
||||
updateWoWPath(detected.absolutePath)
|
||||
logger.info { "Auto-detected WoW at: $detected" }
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(errors = it.errors + ("wowPath" to "Could not find WoW in default locations."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save(): Boolean {
|
||||
val errors = validate()
|
||||
if (errors.isNotEmpty()) {
|
||||
_state.update { it.copy(errors = errors, saveSuccess = false) }
|
||||
return false
|
||||
}
|
||||
|
||||
ConfigManager.save(_state.value.config)
|
||||
_state.update { it.copy(errors = emptyMap(), saveSuccess = true) }
|
||||
logger.info { "Configuration saved" }
|
||||
return true
|
||||
}
|
||||
|
||||
fun resetToSaved() {
|
||||
_state.value = ConfigUiState(config = ConfigManager.config.value)
|
||||
}
|
||||
|
||||
private fun validate(): Map<String, String> {
|
||||
val config = _state.value.config
|
||||
val errors = mutableMapOf<String, String>()
|
||||
|
||||
// WoW path
|
||||
val wowPath = config.wowInstallPath
|
||||
if (wowPath.isNullOrBlank()) {
|
||||
errors["wowPath"] = "WoW install location is required."
|
||||
} else {
|
||||
val dir = File(wowPath)
|
||||
if (!dir.exists()) {
|
||||
errors["wowPath"] = "Directory does not exist."
|
||||
} else {
|
||||
val validation = WoWLocations.validateWoWInstall(dir)
|
||||
if (!validation.isValid) {
|
||||
errors["wowPath"] = validation.error ?: "Not a valid WoW installation."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backup path
|
||||
val backupPath = config.backupPath
|
||||
if (backupPath.isNullOrBlank()) {
|
||||
errors["backupPath"] = "Backup destination is required."
|
||||
} else {
|
||||
val dir = File(backupPath)
|
||||
if (!dir.exists()) {
|
||||
errors["backupPath"] = "Directory does not exist."
|
||||
} else if (!dir.canWrite()) {
|
||||
errors["backupPath"] = "Directory is not writable."
|
||||
}
|
||||
}
|
||||
|
||||
// At least one folder
|
||||
if (!config.backupWtf && !config.backupInterface) {
|
||||
errors["folders"] = "At least one folder must be selected for backup."
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
}
|
||||
11
composeApp/src/jvmMain/resources/logback.xml
Normal file
11
composeApp/src/jvmMain/resources/logback.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<configuration>
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE" />
|
||||
</root>
|
||||
</configuration>
|
||||
154
docs/plans/feature-0-foundation.md
Normal file
154
docs/plans/feature-0-foundation.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Feature 0: Foundation
|
||||
|
||||
## Context
|
||||
Before implementing any user-facing features, we need shared infrastructure: package structure, platform abstraction, config persistence, and logging. Every subsequent feature depends on this.
|
||||
|
||||
## Dependencies
|
||||
- None (this is the base layer)
|
||||
- **Depended on by**: All other features (1-5)
|
||||
|
||||
## New Dependencies to Add
|
||||
|
||||
### gradle/libs.versions.toml
|
||||
| Library | Coordinate | Version | Purpose |
|
||||
|---------|-----------|---------|---------|
|
||||
| kotlinx-serialization-json | org.jetbrains.kotlinx:kotlinx-serialization-json | 1.10.0 | JSON config persistence |
|
||||
| kotlin-logging-jvm | io.github.oshai:kotlin-logging-jvm | 7.0.3 | Kotlin-idiomatic logging |
|
||||
| logback-classic | ch.qos.logback:logback-classic | 1.5.18 | Logging backend with file rotation |
|
||||
|
||||
### Plugins
|
||||
| Plugin | ID | Version |
|
||||
|--------|----|---------|
|
||||
| Kotlin Serialization | org.jetbrains.kotlin.plugin.serialization | (uses kotlin version) |
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
com.rukira.wowbackup/
|
||||
├── main.kt # Entry point (existing, to be updated)
|
||||
├── App.kt # Root composable (existing, to be gutted)
|
||||
├── platform/
|
||||
│ ├── Platform.kt # OS enum + detection
|
||||
│ ├── AppDirectories.kt # Platform-specific app data paths
|
||||
│ └── WoWLocations.kt # Default WoW install detection
|
||||
├── config/
|
||||
│ ├── AppConfig.kt # @Serializable data class
|
||||
│ └── ConfigManager.kt # Read/write JSON config file
|
||||
└── logging/
|
||||
└── LoggingSetup.kt # Logback initialization
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add dependencies
|
||||
**Files to modify:**
|
||||
- `gradle/libs.versions.toml` — add versions, libraries, and serialization plugin
|
||||
- `build.gradle.kts` (root) — register serialization plugin
|
||||
- `composeApp/build.gradle.kts` — apply serialization plugin, add new deps to jvmMain
|
||||
- `settings.gradle.kts` — rename root project from "MyApplication" to "WoWBackup"
|
||||
|
||||
### Step 2: Platform abstraction
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/Platform.kt`
|
||||
|
||||
```kotlin
|
||||
enum class OS { Windows, Mac, Linux, Other }
|
||||
|
||||
object Platform {
|
||||
val current: OS = System.getProperty("os.name").lowercase().let { name ->
|
||||
when {
|
||||
"mac" in name -> OS.Mac
|
||||
"win" in name -> OS.Windows
|
||||
"nux" in name || "nix" in name -> OS.Linux
|
||||
else -> OS.Other
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Files to delete:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/Platform.kt` (old template file)
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/Greeting.kt` (template boilerplate)
|
||||
|
||||
### Step 3: App directories
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/AppDirectories.kt`
|
||||
|
||||
Resolves platform-specific app data directory:
|
||||
- macOS: `~/Library/Application Support/WoWBackup/`
|
||||
- Windows: `%APPDATA%/WoWBackup/`
|
||||
- Linux: `$XDG_CONFIG_HOME/WoWBackup/` (fallback `~/.config/WoWBackup/`)
|
||||
|
||||
Creates directory on first access if it doesn't exist.
|
||||
|
||||
### Step 4: WoW install detection
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/WoWLocations.kt`
|
||||
|
||||
Scans known default install paths:
|
||||
- macOS: `/Applications/World of Warcraft/`, `~/Applications/World of Warcraft/`
|
||||
- Windows: `C:\Program Files (x86)\World of Warcraft\`, `C:\Program Files\World of Warcraft\`
|
||||
|
||||
Validates by checking for WTF folder, WoW executable, or .app bundle.
|
||||
Returns `File?` — null if not found.
|
||||
|
||||
### Step 5: Config data model + persistence
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt`
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class AppConfig(
|
||||
val wowInstallPath: String? = null,
|
||||
val backupPath: String? = null,
|
||||
val backupTimeOfDay: LocalTime = LocalTime(hour = 3, minute = 0), // daily at 3:00 AM
|
||||
val backupHistoryCount: Int = 5,
|
||||
val forceBackupWhileRunning: Boolean = false,
|
||||
val backupWtf: Boolean = true,
|
||||
val backupInterface: Boolean = true,
|
||||
val compressionEnabled: Boolean = false,
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val runAtStartup: Boolean = false,
|
||||
)
|
||||
```
|
||||
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/ConfigManager.kt`
|
||||
|
||||
Responsibilities:
|
||||
- Load config from `<appDataDir>/config.json` (returns defaults if file missing)
|
||||
- Save config to `<appDataDir>/config.json` (pretty-printed JSON)
|
||||
- Expose config as `StateFlow<AppConfig>` for reactive UI updates
|
||||
- `isConfigured: Boolean` — true when wowInstallPath and backupPath are set
|
||||
- Thread-safe reads/writes
|
||||
|
||||
### Step 6: Logging setup
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/resources/logback.xml`
|
||||
|
||||
Configuration:
|
||||
- Console appender (for development)
|
||||
- Rolling file appender to `<appDataDir>/logs/wowbackup.log`
|
||||
- Daily rotation + 10MB max size per file
|
||||
- 30 days retention / 100MB total cap
|
||||
- Pattern: `%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n`
|
||||
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/logging/LoggingSetup.kt`
|
||||
|
||||
Programmatically sets the log directory to the platform-specific app data path at startup (since logback.xml can't reference runtime-resolved paths directly).
|
||||
|
||||
### Step 7: Update entry point
|
||||
**Files to modify:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt`
|
||||
- Initialize logging on startup
|
||||
- Load config via ConfigManager
|
||||
- Update window title to "WoW Backup"
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt`
|
||||
- Strip template boilerplate
|
||||
- Simple placeholder screen showing config status ("Configured" / "Not configured")
|
||||
|
||||
## Verification
|
||||
1. `./gradlew composeApp:run` — app launches, shows placeholder UI
|
||||
2. Check `~/Library/Application Support/WoWBackup/` (macOS) exists after launch
|
||||
3. Check `config.json` is created with defaults after first run
|
||||
4. Check `logs/wowbackup.log` exists and contains startup log entries
|
||||
5. If WoW is installed in default location, verify auto-detection logs the path
|
||||
104
docs/plans/feature-1-system-tray.md
Normal file
104
docs/plans/feature-1-system-tray.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Feature 1: System Tray Icon
|
||||
|
||||
## Context
|
||||
The app lives primarily in the system tray. The window is secondary — it opens for configuration/status and closes back to tray. This is the shell that all UI features plug into.
|
||||
|
||||
## Dependencies
|
||||
- **Depends on**: Feature 0 (Foundation) — logging, platform detection
|
||||
- **Depended on by**: Features 2-5 (all UI features are accessed through the tray)
|
||||
|
||||
## Approach
|
||||
Use Compose Desktop's built-in `Tray` composable (`androidx.compose.ui.window.Tray`). No extra libraries needed — it supports icons, tooltips, context menus, and notifications natively.
|
||||
|
||||
## App Lifecycle Model
|
||||
The app always runs with a tray icon visible. The main window is shown/hidden based on user interaction. Closing the window hides it to tray — it does not exit the app. Exiting is only via the tray menu "Quit" item.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create a tray icon asset
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/resources/tray-icon.png` (32x32, simple recognizable icon)
|
||||
|
||||
For macOS dark mode support, add JVM arg: `-Dapple.awt.enableTemplateImages=true`
|
||||
|
||||
### Step 2: Restructure main.kt for tray-first lifecycle
|
||||
**Files to modify:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt`
|
||||
|
||||
```kotlin
|
||||
fun main() = application {
|
||||
// Initialize logging (from Feature 0)
|
||||
|
||||
var isWindowVisible by remember { mutableStateOf(true) }
|
||||
// Which screen to show: STATUS (default) or CONFIG
|
||||
var currentScreen by remember { mutableStateOf(Screen.STATUS) }
|
||||
|
||||
Tray(
|
||||
icon = painterResource("tray-icon.png"),
|
||||
tooltip = "WoW Backup",
|
||||
onAction = { isWindowVisible = true }, // click tray -> show window
|
||||
menu = {
|
||||
Item("Status", onClick = {
|
||||
currentScreen = Screen.STATUS
|
||||
isWindowVisible = true
|
||||
})
|
||||
Item("Settings", onClick = {
|
||||
currentScreen = Screen.CONFIG
|
||||
isWindowVisible = true
|
||||
})
|
||||
Separator()
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
}
|
||||
)
|
||||
|
||||
if (isWindowVisible) {
|
||||
Window(
|
||||
onCloseRequest = { isWindowVisible = false }, // close -> hide to tray
|
||||
title = "WoW Backup",
|
||||
) {
|
||||
App(currentScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Define screen navigation enum
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/Screen.kt`
|
||||
|
||||
```kotlin
|
||||
enum class Screen { STATUS, CONFIG, RESTORE }
|
||||
```
|
||||
|
||||
### Step 4: Update App.kt for screen routing
|
||||
**Files to modify:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt`
|
||||
|
||||
Strip all template code. Route to placeholder screens based on `currentScreen`:
|
||||
- `Screen.STATUS` -> "Status screen placeholder"
|
||||
- `Screen.CONFIG` -> "Config screen placeholder"
|
||||
- `Screen.RESTORE` -> "Restore screen placeholder"
|
||||
|
||||
### Step 5: Auto-show config if not configured
|
||||
In `main.kt`, on startup check `ConfigManager.isConfigured`. If false, set `currentScreen = Screen.CONFIG` and `isWindowVisible = true`.
|
||||
|
||||
### Step 6: macOS JVM args
|
||||
**Files to modify:**
|
||||
- `composeApp/build.gradle.kts` — add to desktop application config:
|
||||
|
||||
```kotlin
|
||||
compose.desktop {
|
||||
application {
|
||||
jvmArgs("-Dapple.awt.enableTemplateImages=true")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Verification
|
||||
1. `./gradlew composeApp:run` — app launches with tray icon visible
|
||||
2. Tray icon shows context menu with Status, Settings, Quit
|
||||
3. Clicking "Status" or "Settings" opens the window to the correct placeholder
|
||||
4. Closing the window hides it (app stays running in tray)
|
||||
5. Clicking tray icon again re-shows the window
|
||||
6. "Quit" exits the app fully
|
||||
7. On first run (no config), window auto-opens to config screen
|
||||
113
docs/plans/feature-2-configuration.md
Normal file
113
docs/plans/feature-2-configuration.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Feature 2: Configuration Screen
|
||||
|
||||
## Context
|
||||
The configuration screen is the primary setup interface. It appears automatically on first run (when config is incomplete) and is accessible anytime via the tray menu. All backup behavior depends on the values set here.
|
||||
|
||||
## Dependencies
|
||||
- **Depends on**: Feature 0 (ConfigManager, AppConfig), Feature 1 (screen routing, tray menu)
|
||||
- **Depended on by**: Feature 3 (Backup — uses all config values), Feature 4 (Status — shows config warnings)
|
||||
|
||||
## Layout
|
||||
Single scrollable form with section headers. All options visible on one page.
|
||||
|
||||
## File Dialogs
|
||||
Native OS dialogs via `java.awt.FileDialog` (preferred on macOS) or `javax.swing.JFileChooser` (Windows fallback). Folder-selection mode.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Native file/folder picker utility
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/components/NativeFolderPicker.kt`
|
||||
|
||||
A utility function that opens a native folder-selection dialog and returns `File?`:
|
||||
- macOS: Use `java.awt.FileDialog` with `System.setProperty("apple.awt.fileDialogForDirectories", "true")` to enable folder mode
|
||||
- Windows/Linux: Use `JFileChooser` with `DIRECTORIES_ONLY` mode
|
||||
- Runs on the AWT event thread to avoid threading issues
|
||||
|
||||
### Step 2: Create the ConfigViewModel
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt`
|
||||
|
||||
Responsibilities:
|
||||
- Holds a mutable copy of `AppConfig` as UI state
|
||||
- Validates inputs (e.g., paths exist, time is valid)
|
||||
- `save()` — writes to ConfigManager
|
||||
- `detectWoWLocation()` — calls `WoWLocations.findWoWInstall()` and pre-fills if found
|
||||
- Exposes validation errors per field
|
||||
|
||||
### Step 3: Build the ConfigScreen composable
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt`
|
||||
|
||||
Single scrollable `Column` with these sections:
|
||||
|
||||
#### Section: WoW Installation
|
||||
- **WoW install location**: Text field (read-only) + "Browse" button (opens native folder picker)
|
||||
- "Auto-detect" button that scans default locations
|
||||
- Validation: show error if path doesn't exist or doesn't look like a WoW install
|
||||
|
||||
#### Section: Backup Settings
|
||||
- **Backup destination**: Text field (read-only) + "Browse" button
|
||||
- **Backup schedule**: Time picker for daily backup time (hour:minute)
|
||||
- **Backup history**: Dropdown/spinner for how many backups to keep (1-30, default 5)
|
||||
- **Force backup**: Checkbox "Allow backup while WoW is running"
|
||||
- When toggled ON, show a confirmation dialog warning that backing up while WoW runs may capture inconsistent state
|
||||
|
||||
#### Section: Folders to Backup
|
||||
- **WTF folder**: Checkbox (default on) + description text:
|
||||
"Contains account settings, keybinds, macros, and addon saved variables."
|
||||
- **Interface folder**: Checkbox (default on) + description text:
|
||||
"Contains installed addons and their configuration."
|
||||
|
||||
#### Section: Options
|
||||
- **Compression**: Checkbox + description text:
|
||||
"Compress backups to save disk space. Slightly slower backup/restore."
|
||||
- **Notifications**: Checkbox
|
||||
"Show system notifications for backup events."
|
||||
- **Run at startup**: Checkbox
|
||||
"Launch WoW Backup automatically when you log in."
|
||||
|
||||
#### Footer
|
||||
- **Save** button (primary) — validates, saves config, shows success feedback
|
||||
- **Cancel** button — reverts changes, closes window (or navigates to status if first-time setup is incomplete, stays on config)
|
||||
|
||||
### Step 4: Time picker component
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/components/TimePicker.kt`
|
||||
|
||||
Simple hour:minute selector. Two dropdowns (0-23 hours, 0-59 minutes in 15-min increments) or a pair of number fields. Keep it minimal.
|
||||
|
||||
### Step 5: Confirmation dialog for "Force backup"
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/components/ConfirmationDialog.kt`
|
||||
|
||||
Reusable Material3 `AlertDialog` component. Used here for the force-backup warning, and later by the Restore feature (Feature 5).
|
||||
|
||||
### Step 6: Wire into App.kt routing
|
||||
**Files to modify:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt`
|
||||
|
||||
Replace config placeholder with `ConfigScreen(viewModel, onNavigateToStatus)`.
|
||||
|
||||
### Step 7: Run-at-startup implementation (deferred)
|
||||
This is platform-specific and non-trivial:
|
||||
- macOS: LaunchAgent plist in `~/Library/LaunchAgents/`
|
||||
- Windows: Registry key or Start Menu shortcut in Startup folder
|
||||
|
||||
**This will be implemented as a sub-task within this feature**, but the checkbox should be present and functional. The actual OS integration can be a follow-up if it proves complex.
|
||||
|
||||
## Validation Rules
|
||||
- WoW path: must exist, must contain WTF/ or Interface/ or a WoW executable
|
||||
- Backup path: must exist and be writable
|
||||
- At least one folder (WTF or Interface) must be selected
|
||||
- Backup history count: 1-30
|
||||
|
||||
## Verification
|
||||
1. Config screen renders with all sections
|
||||
2. "Browse" buttons open native OS folder picker
|
||||
3. "Auto-detect" finds WoW if installed in default location
|
||||
4. Toggling "Force backup" shows confirmation dialog
|
||||
5. "Save" persists to `config.json`, "Cancel" reverts
|
||||
6. Validation errors display inline for invalid paths
|
||||
7. On first run, config screen opens automatically
|
||||
8. After saving valid config, navigates to status screen
|
||||
144
docs/plans/feature-3-backup.md
Normal file
144
docs/plans/feature-3-backup.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# Feature 3: Backup
|
||||
|
||||
## Context
|
||||
This is the core functionality — automatically backing up WoW's WTF and Interface folders on a daily schedule. The backup runs in the background, respects configuration, and notifies the user of results.
|
||||
|
||||
## Dependencies
|
||||
- **Depends on**: Feature 0 (ConfigManager, logging, platform), Feature 2 (config must be complete)
|
||||
- **Depended on by**: Feature 4 (Status — displays backup state), Feature 5 (Restore — reads backup history)
|
||||
|
||||
## Key Decisions
|
||||
- **Compression**: ZIP format using `java.util.zip` (no extra deps)
|
||||
- **Process detection**: `ProcessHandle.allProcesses()` to check if WoW is running
|
||||
- **Scheduling**: Coroutine-based scheduler, checks every minute if it's time to run
|
||||
- **Backup naming**: Timestamped folders like `2024-01-15_03-00-00/` (or `.zip`)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: WoW process detection
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/WoWProcessDetector.kt`
|
||||
|
||||
```kotlin
|
||||
object WoWProcessDetector {
|
||||
fun isWoWRunning(): Boolean {
|
||||
return ProcessHandle.allProcesses()
|
||||
.filter { it.isAlive }
|
||||
.anyMatch { handle ->
|
||||
val name = handle.info().command().orElse("").lowercase()
|
||||
name.contains("world of warcraft") || name.contains("wow.exe") || name.contains("wow-64")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: File copy engine
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupEngine.kt`
|
||||
|
||||
Responsibilities:
|
||||
- Copy selected folders (WTF, Interface) from WoW install to backup destination
|
||||
- Support two modes: plain copy and ZIP compression
|
||||
- Report progress: total files count, files copied so far, current file name
|
||||
- Cancellable via coroutine cancellation
|
||||
- Return a `BackupResult` (success/failure with details)
|
||||
|
||||
Progress model:
|
||||
```kotlin
|
||||
data class BackupProgress(
|
||||
val totalFiles: Int,
|
||||
val completedFiles: Int,
|
||||
val currentFile: String,
|
||||
)
|
||||
|
||||
sealed class BackupResult {
|
||||
data class Success(val backupPath: File, val sizeBytes: Long, val durationMs: Long) : BackupResult()
|
||||
data class Failure(val reason: String, val exception: Throwable? = null) : BackupResult()
|
||||
}
|
||||
```
|
||||
|
||||
For plain copy:
|
||||
- Walk source directories, replicate structure in `<backupDir>/<timestamp>/`
|
||||
|
||||
For ZIP:
|
||||
- Walk source directories, write to `<backupDir>/<timestamp>.zip` using `ZipOutputStream`
|
||||
|
||||
### Step 3: Backup history management
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt`
|
||||
|
||||
Responsibilities:
|
||||
- List existing backups in the backup directory (sorted by date, newest first)
|
||||
- Parse timestamps from folder/zip names
|
||||
- Prune old backups exceeding `backupHistoryCount`
|
||||
- Return list of `BackupEntry` for the Status and Restore screens
|
||||
|
||||
```kotlin
|
||||
data class BackupEntry(
|
||||
val path: File,
|
||||
val timestamp: LocalDateTime,
|
||||
val isCompressed: Boolean,
|
||||
val sizeBytes: Long,
|
||||
)
|
||||
```
|
||||
|
||||
### Step 4: Backup scheduler
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupScheduler.kt`
|
||||
|
||||
Coroutine-based scheduler that:
|
||||
- Runs in a `CoroutineScope` tied to the application lifecycle
|
||||
- Checks every 60 seconds if the current time matches `backupTimeOfDay` (within a 1-minute window)
|
||||
- Tracks whether today's backup has already run (to avoid duplicate runs)
|
||||
- Before starting backup, checks:
|
||||
1. Config is complete (`ConfigManager.isConfigured`)
|
||||
2. WoW is not running (unless `forceBackupWhileRunning` is true)
|
||||
- Delegates to `BackupEngine` for the actual work
|
||||
- After backup, calls `BackupHistory.pruneOldBackups()`
|
||||
- Exposes state as `StateFlow<SchedulerState>`:
|
||||
|
||||
```kotlin
|
||||
data class SchedulerState(
|
||||
val lastBackupTime: LocalDateTime? = null,
|
||||
val lastBackupResult: BackupResult? = null,
|
||||
val nextBackupTime: LocalDateTime? = null,
|
||||
val isRunning: Boolean = false,
|
||||
val currentProgress: BackupProgress? = null,
|
||||
val isWoWRunning: Boolean = false,
|
||||
)
|
||||
```
|
||||
|
||||
### Step 5: Notifications integration
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupNotifier.kt`
|
||||
|
||||
Uses `TrayState.sendNotification()` (from Compose Desktop's Tray) to send native notifications:
|
||||
- Backup started: "Backup in progress..."
|
||||
- Backup completed: "Backup complete (X files, Y MB)"
|
||||
- Backup failed: "Backup failed: <reason>"
|
||||
- Backup skipped (WoW running): "Backup skipped — WoW is running"
|
||||
|
||||
Only sends if `notificationsEnabled` is true in config.
|
||||
|
||||
### Step 6: Wire scheduler into app lifecycle
|
||||
**Files to modify:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt`
|
||||
|
||||
Start `BackupScheduler` when app launches. Stop when app exits. Pass `TrayState` to `BackupNotifier`.
|
||||
|
||||
### Step 7: Manual backup trigger
|
||||
Add a "Backup Now" item to the tray context menu that triggers an immediate backup (bypassing the schedule, still respecting the WoW-running check).
|
||||
|
||||
**Files to modify:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt` — add menu item
|
||||
|
||||
## Verification
|
||||
1. Set backup time to 1 minute from now, verify backup runs automatically
|
||||
2. Verify WTF and Interface folders are copied correctly to backup destination
|
||||
3. With compression enabled, verify a valid `.zip` is created
|
||||
4. With WoW running and force=false, verify backup is skipped with notification
|
||||
5. With WoW running and force=true, verify backup proceeds
|
||||
6. Verify old backups are pruned when exceeding history count
|
||||
7. "Backup Now" from tray menu triggers immediate backup
|
||||
8. Notifications appear for start/complete/fail (when enabled)
|
||||
9. Check logs for detailed backup operation entries
|
||||
94
docs/plans/feature-4-status.md
Normal file
94
docs/plans/feature-4-status.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Feature 4: Status Screen
|
||||
|
||||
## Context
|
||||
The status screen is the default view when the user clicks the tray icon. It gives a quick overview of backup health and provides shortcuts to key actions.
|
||||
|
||||
## Dependencies
|
||||
- **Depends on**: Feature 0 (config), Feature 1 (screen routing), Feature 3 (SchedulerState, BackupHistory)
|
||||
- **Depended on by**: Feature 5 (Restore — navigated to from status screen)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create the StatusViewModel
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt`
|
||||
|
||||
Collects state from:
|
||||
- `BackupScheduler.state` (StateFlow) — last backup, next backup, running status, progress, WoW status
|
||||
- `ConfigManager.config` (StateFlow) — to check if configured
|
||||
- `BackupHistory.listBackups()` — for backup count summary
|
||||
|
||||
Exposes a single `StatusUiState`:
|
||||
```kotlin
|
||||
data class StatusUiState(
|
||||
val isConfigured: Boolean,
|
||||
val lastBackupTime: String?, // human-readable relative time ("2 hours ago")
|
||||
val lastBackupStatus: String?, // "Success" or "Failed: <reason>"
|
||||
val nextBackupTime: String?, // "Today at 03:00" or "Tomorrow at 03:00"
|
||||
val isWoWRunning: Boolean,
|
||||
val forceBackupEnabled: Boolean,
|
||||
val isBackupRunning: Boolean,
|
||||
val backupProgress: BackupProgress?,
|
||||
val totalBackups: Int,
|
||||
)
|
||||
```
|
||||
|
||||
### Step 2: Build the StatusScreen composable
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt`
|
||||
|
||||
Layout (Material3 Card-based):
|
||||
|
||||
#### Missing Config Warning (if !isConfigured)
|
||||
- Warning banner: "Configuration incomplete. Set up WoW Backup to get started."
|
||||
- "Open Settings" button
|
||||
|
||||
#### Last Backup Card
|
||||
- Icon + "Last backup: 2 hours ago" (or "Never" if no backups)
|
||||
- Status badge: green "Success" / red "Failed: reason"
|
||||
|
||||
#### Next Backup Card
|
||||
- "Next backup: Today at 03:00"
|
||||
- If WoW is running: yellow indicator "WoW is running" + note about force backup status
|
||||
|
||||
#### Backup Progress (if running)
|
||||
- `LinearProgressIndicator` with file count ("142 / 350 files")
|
||||
- Current file name (truncated)
|
||||
|
||||
#### Action Buttons Row
|
||||
- **Backup Now** — triggers immediate backup (disabled if already running)
|
||||
- **Open Backup Folder** — opens backup directory in OS file manager
|
||||
- **Settings** — navigates to config screen
|
||||
- **Restore** — navigates to restore screen
|
||||
|
||||
### Step 3: "Open folder" utility
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt`
|
||||
|
||||
```kotlin
|
||||
object DesktopActions {
|
||||
fun openFolder(path: File) {
|
||||
Desktop.getDesktop().open(path)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Uses `java.awt.Desktop.open()` — works cross-platform.
|
||||
|
||||
### Step 4: Wire into App.kt routing
|
||||
**Files to modify:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt`
|
||||
|
||||
Replace status placeholder with `StatusScreen(viewModel, onNavigateToConfig, onNavigateToRestore)`.
|
||||
|
||||
## Verification
|
||||
1. Status screen shows "Never backed up" on first run
|
||||
2. After a successful backup, shows correct time and "Success" status
|
||||
3. After a failed backup, shows failure reason in red
|
||||
4. While backup is running, progress bar and file count update live
|
||||
5. "Backup Now" triggers immediate backup
|
||||
6. "Open Backup Folder" opens the correct directory in Finder/Explorer
|
||||
7. "Settings" navigates to config screen
|
||||
8. "Restore" navigates to restore screen
|
||||
9. Missing config warning shows when config is incomplete
|
||||
10. WoW running indicator updates in real-time
|
||||
121
docs/plans/feature-5-restore.md
Normal file
121
docs/plans/feature-5-restore.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Feature 5: Restore
|
||||
|
||||
## Context
|
||||
Allows users to restore a previous backup, replacing their current WoW configuration. This is a destructive operation (overwrites current WTF/Interface folders), so it requires clear confirmation and progress feedback.
|
||||
|
||||
## Dependencies
|
||||
- **Depends on**: Feature 0 (config, platform), Feature 1 (screen routing), Feature 3 (BackupHistory, BackupEntry)
|
||||
- **Depended on by**: None (final feature)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Restore engine
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/RestoreEngine.kt`
|
||||
|
||||
Responsibilities:
|
||||
- Given a `BackupEntry`, restore its contents to the WoW install directory
|
||||
- Handle both plain folder backups and ZIP backups
|
||||
- Process:
|
||||
1. Verify source backup exists
|
||||
2. Verify WoW install path exists
|
||||
3. Delete existing WTF/ and/or Interface/ in WoW install (only the ones present in the backup)
|
||||
4. Copy/extract backup contents to WoW install location
|
||||
- Report progress: total files, files restored, current file
|
||||
- Cancellable via coroutine cancellation
|
||||
- Return `RestoreResult` (success/failure)
|
||||
|
||||
```kotlin
|
||||
data class RestoreProgress(
|
||||
val totalFiles: Int,
|
||||
val completedFiles: Int,
|
||||
val currentFile: String,
|
||||
)
|
||||
|
||||
sealed class RestoreResult {
|
||||
data class Success(val restoredFiles: Int, val durationMs: Long) : RestoreResult()
|
||||
data class Failure(val reason: String, val exception: Throwable? = null) : RestoreResult()
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: RestoreViewModel
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/restore/RestoreViewModel.kt`
|
||||
|
||||
State:
|
||||
```kotlin
|
||||
data class RestoreUiState(
|
||||
val backups: List<BackupEntry>, // available backups
|
||||
val selectedBackup: BackupEntry?, // user selection
|
||||
val showConfirmDialog: Boolean,
|
||||
val isRestoring: Boolean,
|
||||
val progress: RestoreProgress?,
|
||||
val result: RestoreResult?,
|
||||
)
|
||||
```
|
||||
|
||||
Actions:
|
||||
- `selectBackup(entry)` — select a backup from the list
|
||||
- `confirmRestore()` — show confirmation dialog
|
||||
- `startRestore()` — begin restore process
|
||||
- `cancelRestore()` — cancel in-progress restore
|
||||
- `dismiss()` — clear result and return to list
|
||||
|
||||
### Step 3: Build the RestoreScreen composable
|
||||
**Files to create:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/restore/RestoreScreen.kt`
|
||||
|
||||
Layout:
|
||||
|
||||
#### Header
|
||||
- "Restore Backup" title
|
||||
- "Open Backup Folder" button
|
||||
|
||||
#### Backup List
|
||||
- Scrollable list of all existing backups
|
||||
- Each row shows:
|
||||
- Date/time in human-readable format ("January 15, 2024 at 3:00 AM")
|
||||
- Size (e.g., "142 MB")
|
||||
- Type badge: "Compressed" or "Folder"
|
||||
- "Restore" button
|
||||
- Empty state: "No backups found. Run a backup first."
|
||||
|
||||
#### Confirmation Dialog (reuses ConfirmationDialog from Feature 2)
|
||||
- Title: "Restore Backup?"
|
||||
- Message: "This will replace your current WoW settings (WTF and Interface folders) with the backup from [date]. This action cannot be undone. Make sure WoW is not running."
|
||||
- Buttons: "Cancel" / "Restore" (destructive style)
|
||||
|
||||
#### Restore Progress (replaces list while restoring)
|
||||
- `LinearProgressIndicator` with percentage
|
||||
- File count: "142 / 350 files"
|
||||
- Current file name
|
||||
- Estimated time remaining (based on average file copy speed)
|
||||
- "Cancel" button
|
||||
|
||||
#### Result
|
||||
- Success: "Restore complete. X files restored."
|
||||
- Failure: "Restore failed: <reason>". Show "Try Again" button.
|
||||
|
||||
### Step 4: Wire into App.kt routing
|
||||
**Files to modify:**
|
||||
- `composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt`
|
||||
|
||||
Replace restore placeholder with `RestoreScreen(viewModel, onNavigateToStatus)`.
|
||||
|
||||
## Edge Cases
|
||||
- WoW is running during restore → warn user but allow (they confirmed)
|
||||
- Backup file is corrupted/missing → show clear error
|
||||
- Insufficient disk space → detect before starting, show error
|
||||
- Restore cancelled mid-way → partial state warning: "Restore was cancelled. Your WoW configuration may be in an incomplete state."
|
||||
|
||||
## Verification
|
||||
1. Restore screen lists all existing backups with correct dates and sizes
|
||||
2. Empty state shows when no backups exist
|
||||
3. Selecting "Restore" shows confirmation dialog with correct date
|
||||
4. Cancelling dialog returns to list without action
|
||||
5. Confirming starts restore with live progress indicator
|
||||
6. Cancel button during restore stops the operation
|
||||
7. Successful restore copies all files to WoW install directory
|
||||
8. ZIP backups are extracted correctly
|
||||
9. Folder backups are copied correctly
|
||||
10. Error states display clearly (corrupted backup, missing paths)
|
||||
|
|
@ -3,13 +3,20 @@ androidx-lifecycle = "2.10.0-alpha08"
|
|||
composeMultiplatform = "1.11.0-alpha03"
|
||||
kotlin = "2.2.21"
|
||||
kotlinx-coroutines = "1.10.2"
|
||||
kotlinx-serialization = "1.10.0"
|
||||
kotlin-logging = "7.0.3"
|
||||
logback = "1.5.18"
|
||||
|
||||
[libraries]
|
||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
kotlin-logging-jvm = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "kotlin-logging" }
|
||||
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||
|
||||
[plugins]
|
||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
rootProject.name = "MyApplication"
|
||||
rootProject.name = "WoWBackup"
|
||||
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||||
|
||||
pluginManagement {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue