Config screen

This commit is contained in:
Rukira 2026-03-04 14:19:19 +00:00
parent ac88e62abe
commit 4b3c512a9d
28 changed files with 2015 additions and 59 deletions

View file

@ -4,4 +4,5 @@ plugins {
alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.kotlinSerialization) apply false
} }

View file

@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
} }
kotlin { kotlin {
@ -23,6 +24,9 @@ kotlin {
jvmMain.dependencies { jvmMain.dependencies {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing) 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 { application {
mainClass = "com.rukira.wowbackup.MainKt" mainClass = "com.rukira.wowbackup.MainKt"
jvmArgs("-Dapple.awt.enableTemplateImages=true")
nativeDistributions { nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.rukira.wowbackup" packageName = "com.rukira.wowbackup"

View file

@ -1,49 +1,60 @@
package com.rukira.wowbackup package com.rukira.wowbackup
import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.painterResource import androidx.compose.ui.unit.dp
import org.jetbrains.compose.ui.tooling.preview.Preview import com.rukira.wowbackup.ui.Screen
import com.rukira.wowbackup.ui.config.ConfigScreen
import myapplication.composeapp.generated.resources.Res import com.rukira.wowbackup.ui.config.ConfigViewModel
import myapplication.composeapp.generated.resources.compose_multiplatform
@Composable @Composable
@Preview fun App(
fun App() { currentScreen: Screen,
onNavigate: (Screen) -> Unit,
) {
MaterialTheme { 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( Column(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(24.dp),
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) { ) {
Button(onClick = { showContent = !showContent }) { Text(
Text("Click me!") text = title,
} style = MaterialTheme.typography.headlineMedium,
AnimatedVisibility(showContent) { )
val greeting = remember { Greeting().greet() } Text(
Column( text = subtitle,
modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyLarge,
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(top = 8.dp),
) { color = MaterialTheme.colorScheme.onSurfaceVariant,
Image(painterResource(Res.drawable.compose_multiplatform), null) )
Text("Compose: $greeting")
}
}
}
} }
} }

View file

@ -1,9 +0,0 @@
package com.rukira.wowbackup
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}

View file

@ -1,7 +0,0 @@
package com.rukira.wowbackup
class JVMPlatform {
val name: String = "Java ${System.getProperty("java.version")}"
}
fun getPlatform() = JVMPlatform()

View file

@ -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()
}

View file

@ -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" }
}
}
}

View 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)
}
}

View file

@ -1,13 +1,67 @@
package com.rukira.wowbackup 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.Window
import androidx.compose.ui.window.application 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( Window(
onCloseRequest = ::exitApplication, onCloseRequest = { isWindowVisible = false },
title = "MyApplication", title = "WoW Backup",
) { ) {
App() App(
currentScreen = currentScreen,
onNavigate = { currentScreen = it },
)
}
}
} }
} }

View file

@ -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")
}
}
}

View file

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

View file

@ -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(),
)

View file

@ -0,0 +1,7 @@
package com.rukira.wowbackup.ui
enum class Screen {
STATUS,
CONFIG,
RESTORE,
}

View file

@ -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),
)
}
}

View file

@ -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)
}
},
)
}

View file

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

View file

@ -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
},
)
}
}
}
}

View file

@ -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("+")
}
}
}

View file

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

View 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>

View 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

View 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

View 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

View 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

View 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

View 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)

View file

@ -3,13 +3,20 @@ androidx-lifecycle = "2.10.0-alpha08"
composeMultiplatform = "1.11.0-alpha03" composeMultiplatform = "1.11.0-alpha03"
kotlin = "2.2.21" kotlin = "2.2.21"
kotlinx-coroutines = "1.10.2" kotlinx-coroutines = "1.10.2"
kotlinx-serialization = "1.10.0"
kotlin-logging = "7.0.3"
logback = "1.5.18"
[libraries] [libraries]
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } 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" } 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-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] [plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

View file

@ -1,4 +1,4 @@
rootProject.name = "MyApplication" rootProject.name = "WoWBackup"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement { pluginManagement {