From 923835203aa7551c4596dfaba0609d7a77758fed Mon Sep 17 00:00:00 2001 From: Rukira Date: Wed, 4 Mar 2026 14:40:04 +0000 Subject: [PATCH 1/5] Make it pretty --- composeApp/build.gradle.kts | 1 + .../kotlin/com/rukira/wowbackup/App.kt | 9 ++- .../com/rukira/wowbackup/config/AppConfig.kt | 20 ++++++ .../wowbackup/ui/config/ConfigScreen.kt | 70 +++++++++++++++++++ .../wowbackup/ui/config/ConfigViewModel.kt | 16 +++++ .../wowbackup/ui/status/StatusScreen.kt | 2 +- .../com/rukira/wowbackup/ui/theme/Theme.kt | 26 +++++++ gradle/libs.versions.toml | 2 + 8 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ea1e38a..7f8ac7e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -29,6 +29,7 @@ kotlin { implementation(libs.cardiologist) implementation(libs.kotlin.logging.jvm) implementation(libs.logback.classic) + implementation(libs.material.kolor) } } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt index e053ea8..f417ce0 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt @@ -8,22 +8,28 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.rukira.wowbackup.config.ConfigManager import com.rukira.wowbackup.ui.Screen import com.rukira.wowbackup.ui.config.ConfigScreen import com.rukira.wowbackup.ui.config.ConfigViewModel import com.rukira.wowbackup.ui.status.StatusScreen import com.rukira.wowbackup.ui.status.StatusViewModel +import com.rukira.wowbackup.ui.theme.WoWBackupTheme @Composable fun App( currentScreen: Screen, onNavigate: (Screen) -> Unit, ) { - MaterialTheme { + val config by ConfigManager.config.collectAsState() + + WoWBackupTheme(themeMode = config.themeMode, accentColor = config.accentColor) { Surface(modifier = Modifier.fillMaxSize()) { when (currentScreen) { Screen.STATUS -> { @@ -48,6 +54,7 @@ fun App( } } + @Composable private fun PlaceholderScreen(title: String, subtitle: String) { Column( diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt index 65ddf1d..ae7649e 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt @@ -1,8 +1,26 @@ package com.rukira.wowbackup.config +import androidx.compose.ui.graphics.Color import kotlinx.datetime.LocalTime import kotlinx.serialization.Serializable +@Serializable +enum class ThemeMode { SYSTEM, LIGHT, DARK } + +@Serializable +enum class AccentColor(val displayName: String, private val colorValue: Long) { + PURPLE("Purple", 0xFF7C4DFF), + BLUE("Blue", 0xFF448AFF), + TEAL("Teal", 0xFF1DE9B6), + GREEN("Green", 0xFF69F0AE), + ORANGE("Orange", 0xFFFF9100), + RED("Red", 0xFFFF5252), + PINK("Pink", 0xFFFF4081), + INDIGO("Indigo", 0xFF536DFE); + + val seedColor: Color get() = Color(colorValue) +} + @Serializable data class AppConfig( val wowInstallPath: String? = null, @@ -15,6 +33,8 @@ data class AppConfig( val compressionEnabled: Boolean = false, val notificationsEnabled: Boolean = true, val runAtStartup: Boolean = false, + val themeMode: ThemeMode = ThemeMode.SYSTEM, + val accentColor: AccentColor = AccentColor.PURPLE, ) { val isConfigured: Boolean get() = !wowInstallPath.isNullOrBlank() && !backupPath.isNullOrBlank() diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt index a5ef35a..593c80c 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt @@ -1,6 +1,10 @@ package com.rukira.wowbackup.ui.config +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -8,8 +12,10 @@ 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Checkbox @@ -17,6 +23,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -26,7 +35,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp +import com.rukira.wowbackup.config.AccentColor +import com.rukira.wowbackup.config.ThemeMode import com.rukira.wowbackup.ui.components.ConfirmationDialog import com.rukira.wowbackup.ui.components.TimePicker import com.rukira.wowbackup.ui.components.pickFolder @@ -185,6 +197,64 @@ fun ConfigScreen( description = "Launch WoW Backup automatically when you log in.", ) + // === Appearance === + SectionHeader("Appearance") + + Text("Theme", style = MaterialTheme.typography.bodyMedium) + SingleChoiceSegmentedButtonRow { + ThemeMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + selected = config.themeMode == mode, + onClick = { viewModel.updateThemeMode(mode) }, + shape = SegmentedButtonDefaults.itemShape(index, ThemeMode.entries.size), + ) { + Text( + when (mode) { + ThemeMode.SYSTEM -> "System" + ThemeMode.LIGHT -> "Light" + ThemeMode.DARK -> "Dark" + }, + ) + } + } + } + + Text("Accent color", style = MaterialTheme.typography.bodyMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AccentColor.entries.forEach { color -> + val isSelected = config.accentColor == color + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(color.seedColor, CircleShape) + .then( + if (isSelected) { + Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + } else { + Modifier + }, + ) + .clickable { viewModel.updateAccentColor(color) }, + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Text( + "\u2713", + color = MaterialTheme.colorScheme.surface, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + + Text( + "Theme changes apply immediately.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // === Footer === Spacer(Modifier.height(8.dp)) HorizontalDivider() diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt index 345ca6d..c2fb730 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt @@ -1,8 +1,10 @@ package com.rukira.wowbackup.ui.config import androidx.lifecycle.ViewModel +import com.rukira.wowbackup.config.AccentColor import com.rukira.wowbackup.config.AppConfig import com.rukira.wowbackup.config.ConfigManager +import com.rukira.wowbackup.config.ThemeMode import com.rukira.wowbackup.platform.WoWLocations import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.MutableStateFlow @@ -134,6 +136,20 @@ class ConfigViewModel : ViewModel() { } } + fun updateThemeMode(mode: ThemeMode) { + _state.update { + it.copy(config = it.config.copy(themeMode = mode)) + } + ConfigManager.save(_state.value.config) + } + + fun updateAccentColor(color: AccentColor) { + _state.update { + it.copy(config = it.config.copy(accentColor = color)) + } + ConfigManager.save(_state.value.config) + } + fun detectWoWLocation() { val detected = WoWLocations.findWoWInstall() if (detected != null) { diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt index e26d6a6..5a77680 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt @@ -205,7 +205,7 @@ fun StatusScreen( }, enabled = uiState.backupPath != null, ) { - Text("Open Folder") + Text("Open Backups Folder") } OutlinedButton(onClick = onNavigateToRestore, enabled = false) { Text("Restore (Coming Soon)") diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt new file mode 100644 index 0000000..9b4c7e7 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt @@ -0,0 +1,26 @@ +package com.rukira.wowbackup.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import com.materialkolor.DynamicMaterialTheme +import com.rukira.wowbackup.config.AccentColor +import com.rukira.wowbackup.config.ThemeMode + +@Composable +fun WoWBackupTheme( + themeMode: ThemeMode = ThemeMode.SYSTEM, + accentColor: AccentColor = AccentColor.PURPLE, + content: @Composable () -> Unit, +) { + val useDarkTheme = when (themeMode) { + ThemeMode.SYSTEM -> isSystemInDarkTheme() + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } + + DynamicMaterialTheme( + seedColor = accentColor.seedColor, + useDarkTheme = useDarkTheme, + content = content, + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e0c7f93..9e629fd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ kotlinx-serialization = "1.10.0" cardiologist = "0.8.0" kotlin-logging = "7.0.3" logback = "1.5.18" +materialKolor = "2.0.0" [libraries] androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } @@ -18,6 +19,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" } 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" } +material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } [plugins] composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } From 44656c81d52c702ab67611ce5fa46435e7409571 Mon Sep 17 00:00:00 2001 From: Rukira Date: Wed, 4 Mar 2026 15:00:31 +0000 Subject: [PATCH 2/5] Performance --- .../rukira/wowbackup/backup/BackupHistory.kt | 30 ++++++++++++++++--- .../wowbackup/backup/BackupScheduler.kt | 23 ++++++++++---- .../wowbackup/ui/status/StatusViewModel.kt | 2 +- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt index e140a2d..f03c20d 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt @@ -17,12 +17,29 @@ class BackupEntry( val sizeBytes: Long by lazy { sizeComputer() } } +data class BackupMetrics(val fileCount: Int, val sizeBytes: Long) + object BackupHistory { // Used internally for parsing — java.time formatter since kotlinx.datetime // doesn't have custom format patterns built in private val JAVA_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") + /** + * Counts backups without parsing timestamps or sorting — just matches the naming pattern. + */ + fun countBackups(backupDir: File): Int { + if (!backupDir.exists() || !backupDir.isDirectory) return 0 + return backupDir.listFiles()?.count { file -> + val name = file.nameWithoutExtension + val isZip = file.extension.equals("zip", ignoreCase = true) && file.isFile + val isDir = file.isDirectory + if (!isZip && !isDir) return@count false + try { java.time.LocalDateTime.parse(name, JAVA_TIMESTAMP_FORMAT); true } + catch (_: DateTimeParseException) { false } + } ?: 0 + } + /** * Lists all existing backups in the backup directory, sorted newest first. */ @@ -37,13 +54,18 @@ object BackupHistory { } /** - * Returns the file count for a backup entry without computing its full size. + * Computes file count and size in a single pass over the backup tree. */ - fun fileCount(entry: BackupEntry): Int { + fun computeMetrics(entry: BackupEntry): BackupMetrics { return if (entry.isCompressed) { - java.util.zip.ZipFile(entry.path).use { it.size() } + java.util.zip.ZipFile(entry.path).use { zip -> + BackupMetrics(zip.size(), entry.path.length()) + } } else { - entry.path.walkTopDown().count { it.isFile } + var count = 0 + var size = 0L + entry.path.walkTopDown().filter { it.isFile }.forEach { count++; size += it.length() } + BackupMetrics(count, size) } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupScheduler.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupScheduler.kt index f8f7e85..552f1f5 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupScheduler.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupScheduler.kt @@ -46,6 +46,19 @@ object BackupScheduler { if (schedulerJob?.isActive == true) return logger.info { "Backup scheduler started" } + // Eagerly set timestamps so StatusScreen has data immediately. + // Only set lastBackupTime and nextBackupTime here (cheap). + // The full lastBackupResult (fileCount, sizeBytes) is filled by the async path + // since fileCount() walks the directory tree and would block the main thread. + val config = ConfigManager.config.value + if (config.isConfigured && config.backupPath != null) { + val backups = BackupHistory.listBackups(File(config.backupPath)) + if (backups.isNotEmpty()) { + _state.update { it.copy(lastBackupTime = backups.first().timestamp) } + } + updateNextBackupTime(TimeZone.currentSystemDefault()) + } + schedulerJob = scope.launch { // Collect progress from BackupEngine launch { @@ -65,23 +78,23 @@ object BackupScheduler { } // On first config emission, restore last backup info from disk - if (_state.value.lastBackupTime == null && config.backupPath != null) { + if (_state.value.lastBackupResult == null && config.backupPath != null) { val backups = BackupHistory.listBackups(File(config.backupPath)) if (backups.isNotEmpty()) { val latest = backups.first() - val fileCount = BackupHistory.fileCount(latest) + val metrics = BackupHistory.computeMetrics(latest) _state.update { it.copy( lastBackupTime = latest.timestamp, lastBackupResult = BackupResult.Success( backupPath = latest.path, - fileCount = fileCount, - sizeBytes = latest.sizeBytes, + fileCount = metrics.fileCount, + sizeBytes = metrics.sizeBytes, durationMs = 0, ), ) } - logger.info { "Restored last backup info from disk: ${latest.path.name} ($fileCount files)" } + logger.info { "Restored last backup info from disk: ${latest.path.name} (${metrics.fileCount} files)" } } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt index 08a3fee..ff36ca0 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt @@ -43,7 +43,7 @@ class StatusViewModel : ViewModel() { BackupScheduler.state.map { it.lastBackupResult }.distinctUntilChanged(), ) { backupPath, _ -> if (backupPath != null) { - BackupHistory.listBackups(File(backupPath)).size + BackupHistory.countBackups(File(backupPath)) } else { 0 } From 4036febb702e514633b663483c3cd18f1f50c9be Mon Sep 17 00:00:00 2001 From: Rukira Date: Wed, 4 Mar 2026 15:13:18 +0000 Subject: [PATCH 3/5] Run on startup --- .../kotlin/com/rukira/wowbackup/main.kt | 8 +- .../wowbackup/platform/StartupManager.kt | 127 ++++++++++++++++++ .../wowbackup/ui/config/ConfigViewModel.kt | 2 + 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/StartupManager.kt diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt index 8432651..160eddc 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt @@ -19,6 +19,7 @@ import com.rukira.wowbackup.config.ConfigManager import com.rukira.wowbackup.logging.LoggingSetup import com.rukira.wowbackup.platform.OS import com.rukira.wowbackup.platform.Platform +import com.rukira.wowbackup.platform.StartupManager import com.rukira.wowbackup.platform.WoWLocations import com.rukira.wowbackup.ui.Screen import com.rukira.wowbackup.ui.trayIconPainter @@ -34,6 +35,9 @@ fun main() { ConfigManager.load() logger.info { "Config loaded. Configured: ${ConfigManager.isConfigured}" } + // Sync OS startup registration with persisted config + StartupManager.setEnabled(ConfigManager.config.value.runAtStartup) + val detectedWoW = WoWLocations.findWoWInstall() if (detectedWoW != null) { logger.info { "Auto-detected WoW at: $detectedWoW" } @@ -67,10 +71,6 @@ fun main() { isWindowVisible = true }) Separator() - Item("Backup Now", onClick = { - BackupScheduler.triggerBackupNow() - }) - Separator() Item("Quit", onClick = { BackupScheduler.stop() exitApplication() diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/StartupManager.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/StartupManager.kt new file mode 100644 index 0000000..8cb15f7 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/StartupManager.kt @@ -0,0 +1,127 @@ +package com.rukira.wowbackup.platform + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.File + +private val logger = KotlinLogging.logger {} + +object StartupManager { + + private const val APP_ID = "com.rukira.wowbackup" + + fun setEnabled(enabled: Boolean) { + val appPath = getAppPath() + if (appPath == null) { + logger.warn { "Cannot register startup: not running from a packaged app (dev mode?)" } + return + } + + logger.info { "Setting launch-at-startup to $enabled (appPath=$appPath)" } + + when (Platform.current) { + OS.Mac -> if (enabled) enableMac(appPath) else disableMac() + OS.Windows -> if (enabled) enableWindows(appPath) else disableWindows() + else -> logger.warn { "Launch at startup is not supported on ${Platform.current}" } + } + } + + private fun getAppPath(): String? = when (Platform.current) { + OS.Mac -> { + // java.home is e.g. /Applications/com.rukira.wowbackup.app/Contents/runtime/Contents/Home + val javaHome = System.getProperty("java.home") ?: return null + val marker = ".app/Contents" + val idx = javaHome.indexOf(marker) + if (idx < 0) null else javaHome.substring(0, idx + ".app".length) + } + OS.Windows -> { + ProcessHandle.current().info().command().orElse(null)?.takeIf { it.endsWith(".exe", ignoreCase = true) } + } + else -> null + } + + // --- macOS: LaunchAgent plist --- + + private val plistFile: File + get() = File(System.getProperty("user.home"), "Library/LaunchAgents/$APP_ID.plist") + + private fun enableMac(appPath: String) { + val plist = """ + | + | + | + | + | Label + | $APP_ID + | ProgramArguments + | + | open + | -a + | $appPath + | + | RunAtLoad + | + | + | + """.trimMargin() + + try { + plistFile.parentFile.mkdirs() + plistFile.writeText(plist) + logger.info { "LaunchAgent plist written to ${plistFile.absolutePath}" } + } catch (e: Exception) { + logger.error(e) { "Failed to write LaunchAgent plist" } + } + } + + private fun disableMac() { + try { + if (plistFile.exists()) { + plistFile.delete() + logger.info { "LaunchAgent plist removed" } + } + } catch (e: Exception) { + logger.error(e) { "Failed to remove LaunchAgent plist" } + } + } + + // --- Windows: Registry --- + + private const val REG_KEY = """HKCU\Software\Microsoft\Windows\CurrentVersion\Run""" + private const val REG_VALUE = "WoWBackup" + + private fun enableWindows(appPath: String) { + try { + val process = ProcessBuilder( + "reg", "add", REG_KEY, "/v", REG_VALUE, "/d", appPath, "/f" + ).redirectErrorStream(true).start() + val exitCode = process.waitFor() + if (exitCode == 0) { + logger.info { "Registry startup entry added" } + } else { + val output = process.inputStream.bufferedReader().readText() + logger.error { "reg add failed (exit $exitCode): $output" } + } + } catch (e: Exception) { + logger.error(e) { "Failed to add registry startup entry" } + } + } + + private fun disableWindows() { + try { + val process = ProcessBuilder( + "reg", "delete", REG_KEY, "/v", REG_VALUE, "/f" + ).redirectErrorStream(true).start() + val exitCode = process.waitFor() + if (exitCode == 0) { + logger.info { "Registry startup entry removed" } + } else { + val output = process.inputStream.bufferedReader().readText() + // Exit code 1 with "not found" is expected when disabling and key doesn't exist + logger.warn { "reg delete exited $exitCode: $output" } + } + } catch (e: Exception) { + logger.error(e) { "Failed to remove registry startup entry" } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt index c2fb730..d78b938 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt @@ -5,6 +5,7 @@ import com.rukira.wowbackup.config.AccentColor import com.rukira.wowbackup.config.AppConfig import com.rukira.wowbackup.config.ConfigManager import com.rukira.wowbackup.config.ThemeMode +import com.rukira.wowbackup.platform.StartupManager import com.rukira.wowbackup.platform.WoWLocations import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.MutableStateFlow @@ -170,6 +171,7 @@ class ConfigViewModel : ViewModel() { } ConfigManager.save(_state.value.config) + StartupManager.setEnabled(_state.value.config.runAtStartup) _state.update { it.copy(errors = emptyMap(), saveSuccess = true) } logger.info { "Configuration saved" } return true From d738b133d0c177f640006161737a91e393abbef9 Mon Sep 17 00:00:00 2001 From: Rukira Date: Wed, 4 Mar 2026 15:17:57 +0000 Subject: [PATCH 4/5] Run on startup --- .../kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt index 593c80c..8f3bbad 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.rukira.wowbackup.config.AccentColor import com.rukira.wowbackup.config.ThemeMode +import com.rukira.wowbackup.platform.AppDirectories +import com.rukira.wowbackup.platform.DesktopActions import com.rukira.wowbackup.ui.components.ConfirmationDialog import com.rukira.wowbackup.ui.components.TimePicker import com.rukira.wowbackup.ui.components.pickFolder @@ -265,6 +267,10 @@ fun ConfigScreen( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { + OutlinedButton(onClick = { DesktopActions.openFolder(AppDirectories.logsDir) }) { + Text("Open Logs") + } + Spacer(Modifier.weight(1f)) if (uiState.saveSuccess) { Text( "Saved!", From 17ba07deef44402e2caefbb74fe0911a6b872481 Mon Sep 17 00:00:00 2001 From: Rukira Date: Wed, 4 Mar 2026 15:33:29 +0000 Subject: [PATCH 5/5] Random small improvements --- .gitea/issue_template/bug_report.yaml | 94 +++++++++++++++++++ composeApp/build.gradle.kts | 29 +++++- .../kotlin/com/rukira/wowbackup/main.kt | 27 ++---- .../wowbackup/ui/config/ConfigScreen.kt | 23 ++++- .../wowbackup/ui/status/StatusScreen.kt | 10 ++ 5 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 .gitea/issue_template/bug_report.yaml diff --git a/.gitea/issue_template/bug_report.yaml b/.gitea/issue_template/bug_report.yaml new file mode 100644 index 0000000..de6325e --- /dev/null +++ b/.gitea/issue_template/bug_report.yaml @@ -0,0 +1,94 @@ +name: Bug Report +about: Something not working right? Let us know so we can fix it! +title: "[Bug] " +labels: + - bug + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report this! The more detail you can give, the faster we can track it down. + Don't worry about being too technical — just describe what happened in your own words. + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Tell us what went wrong. What did you see or experience? + placeholder: "e.g. The app froze after I clicked Save, and my backup never ran." + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + placeholder: "e.g. I expected my settings to save and the backup to start at the scheduled time." + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: | + Walk us through exactly what you did before the problem showed up. + Try to be specific — even small details can help! Number each step. + placeholder: | + 1. Opened WoW Backup + 2. Went to Settings + 3. Changed the backup time to 5:00 PM + 4. Clicked Save + 5. The app froze and stopped responding + validations: + required: true + + - type: dropdown + id: frequency + attributes: + label: How often does this happen? + options: + - Every time + - Sometimes + - It only happened once + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating system + options: + - macOS + - Windows + validations: + required: true + + - type: input + id: app-version + attributes: + label: App version + description: You can find this in the app's title bar or About screen. + placeholder: "e.g. 1.0.0" + + - type: textarea + id: logs + attributes: + label: Log file + description: | + Logs help us understand what went wrong behind the scenes. Here's how to find them: + + **macOS:** Open Finder, press **Cmd+Shift+G**, and paste: `~/Library/Application Support/WoWBackup/logs` + **Windows:** Press **Win+R**, and paste: `%APPDATA%\WoWBackup\logs` + + You can also open this folder from the app: go to **Settings** and click the **Open Logs** button at the bottom. + + Attach the most recent `wowbackup.log` file by dragging it into this text box. + placeholder: Drag your log file here, or paste any relevant log lines. + + - type: textarea + id: extra + attributes: + label: Anything else? + description: Screenshots, screen recordings, or any other context that might help. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 7f8ac7e..53c56a0 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -34,6 +34,33 @@ kotlin { } } +val appVersion = "0.1.0" + +val generatedSrcDir = layout.buildDirectory.dir("generated/src/jvmMain/kotlin") + +val generateBuildConfig by tasks.registering { + val outputDir = generatedSrcDir + val version = appVersion + outputs.dir(outputDir) + doLast { + val dir = outputDir.get().asFile.resolve("com/rukira/wowbackup") + dir.mkdirs() + dir.resolve("BuildConfig.kt").writeText( + """ + |package com.rukira.wowbackup + | + |object BuildConfig { + | const val VERSION = "$version" + |} + """.trimMargin() + ) + } +} + +kotlin.sourceSets.named("jvmMain") { + kotlin.srcDir(generateBuildConfig) +} + compose.desktop { application { mainClass = "com.rukira.wowbackup.MainKt" @@ -43,7 +70,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "com.rukira.wowbackup" - packageVersion = "1.0.0" + packageVersion = appVersion } } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt index 160eddc..1287b35 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application -import androidx.compose.ui.window.TrayState import androidx.compose.ui.window.rememberTrayState import com.rukira.wowbackup.backup.BackupNotifier import com.rukira.wowbackup.backup.BackupScheduler @@ -43,14 +42,13 @@ fun main() { logger.info { "Auto-detected WoW at: $detectedWoW" } } - val startScreen = if (ConfigManager.isConfigured) Screen.STATUS else Screen.CONFIG + val isConfigured = ConfigManager.isConfigured // Start backup scheduler BackupScheduler.start() application { - var isWindowVisible by remember { mutableStateOf(true) } - var currentScreen by remember { mutableStateOf(startScreen) } + var currentScreen by remember { mutableStateOf(if (isConfigured) null else Screen.CONFIG) } val trayState = rememberTrayState() // Wire up notifications @@ -60,16 +58,10 @@ fun main() { state = trayState, icon = trayIconPainter(), tooltip = "WoW Backup", - onAction = { isWindowVisible = true }, + onAction = { currentScreen = Screen.STATUS }, menu = { - Item("Status", onClick = { - currentScreen = Screen.STATUS - isWindowVisible = true - }) - Item("Settings", onClick = { - currentScreen = Screen.CONFIG - isWindowVisible = true - }) + Item("Status", onClick = { currentScreen = Screen.STATUS }) + Item("Settings", onClick = { currentScreen = Screen.CONFIG }) Separator() Item("Quit", onClick = { BackupScheduler.stop() @@ -78,8 +70,9 @@ fun main() { }, ) - if (isWindowVisible) { - val windowState = remember(isWindowVisible) { + val activeScreen = currentScreen + if (activeScreen != null) { + val windowState = remember(activeScreen) { WindowState( size = DpSize(480.dp, 620.dp), position = trayAlignedPosition(), @@ -87,13 +80,13 @@ fun main() { } Window( - onCloseRequest = { isWindowVisible = false }, + onCloseRequest = { currentScreen = null }, title = "WoW Backup", state = windowState, resizable = true, ) { App( - currentScreen = currentScreen, + currentScreen = activeScreen, onNavigate = { currentScreen = it }, ) } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt index 8f3bbad..63922c0 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt @@ -8,14 +8,19 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.ScrollbarStyle +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Checkbox @@ -56,11 +61,13 @@ fun ConfigScreen( val config = uiState.config val errors = uiState.errors var showForceBackupDialog by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { @@ -296,6 +303,20 @@ fun ConfigScreen( } } + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + adapter = rememberScrollbarAdapter(scrollState), + style = ScrollbarStyle( + minimalHeight = 48.dp, + thickness = 8.dp, + shape = RoundedCornerShape(4.dp), + hoverDurationMillis = 300, + unhoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + hoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ), + ) + } + // Force backup confirmation dialog if (showForceBackupDialog) { ConfirmationDialog( diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt index 5a77680..8809df5 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.rukira.wowbackup.BuildConfig import com.rukira.wowbackup.backup.BackupScheduler import com.rukira.wowbackup.platform.DesktopActions import java.io.File @@ -211,5 +212,14 @@ fun StatusScreen( Text("Restore (Coming Soon)") } } + + Spacer(Modifier.weight(1f)) + Text( + "WoW Backup v${BuildConfig.VERSION}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) } }