Backup process

This commit is contained in:
Rukira 2026-03-04 14:19:19 +00:00
parent 4b3c512a9d
commit 171bf78be8
11 changed files with 596 additions and 19 deletions

View file

@ -0,0 +1,171 @@
package com.rukira.wowbackup.backup
import com.rukira.wowbackup.config.AppConfig
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
private val logger = KotlinLogging.logger {}
data class BackupProgress(
val totalFiles: Int,
val completedFiles: Int,
val currentFile: String,
)
sealed class BackupResult {
data class Success(val backupPath: File, val fileCount: Int, val sizeBytes: Long, val durationMs: Long) : BackupResult()
data class Failure(val reason: String, val exception: Throwable? = null) : BackupResult()
}
object BackupEngine {
private val TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")
private val _progress = MutableStateFlow<BackupProgress?>(null)
val progress: StateFlow<BackupProgress?> = _progress.asStateFlow()
suspend fun runBackup(config: AppConfig): BackupResult {
val wowPath = config.wowInstallPath ?: return BackupResult.Failure("WoW install path not set.")
val backupPath = config.backupPath ?: return BackupResult.Failure("Backup path not set.")
val wowDir = File(wowPath)
val backupDir = File(backupPath)
if (!wowDir.exists()) return BackupResult.Failure("WoW directory does not exist: $wowPath")
if (!backupDir.exists()) return BackupResult.Failure("Backup directory does not exist: $backupPath")
// Collect source folders
val sourceFolders = buildList {
if (config.backupWtf) {
val wtf = File(wowDir, "WTF")
if (wtf.exists()) add(wtf) else logger.warn { "WTF folder not found at $wtf" }
}
if (config.backupInterface) {
val iface = File(wowDir, "Interface")
if (iface.exists()) add(iface) else logger.warn { "Interface folder not found at $iface" }
}
}
if (sourceFolders.isEmpty()) {
return BackupResult.Failure("No folders found to backup.")
}
// Count total files
val allFiles = sourceFolders.flatMap { folder ->
folder.walkTopDown().filter { it.isFile }.toList()
}
val totalFiles = allFiles.size
logger.info { "Starting backup of $totalFiles files from ${sourceFolders.map { it.name }}" }
val startTime = System.currentTimeMillis()
val timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT)
return try {
if (config.compressionEnabled) {
backupZip(wowDir, sourceFolders, allFiles, backupDir, timestamp, totalFiles)
} else {
backupCopy(wowDir, sourceFolders, allFiles, backupDir, timestamp, totalFiles)
}
} catch (e: kotlinx.coroutines.CancellationException) {
logger.info { "Backup cancelled" }
throw e
} catch (e: Exception) {
logger.error(e) { "Backup failed" }
BackupResult.Failure("Backup failed: ${e.message}", e)
} finally {
_progress.value = null
val elapsed = System.currentTimeMillis() - startTime
logger.info { "Backup operation finished in ${elapsed}ms" }
}
}
private suspend fun backupCopy(
wowDir: File,
sourceFolders: List<File>,
allFiles: List<File>,
backupDir: File,
timestamp: String,
totalFiles: Int,
): BackupResult {
val destDir = File(backupDir, timestamp)
destDir.mkdirs()
var completed = 0
var totalSize = 0L
for (folder in sourceFolders) {
for (file in folder.walkTopDown().filter { it.isFile }) {
currentCoroutineContext().ensureActive()
val relativePath = file.relativeTo(wowDir)
val destFile = File(destDir, relativePath.path)
destFile.parentFile?.mkdirs()
_progress.value = BackupProgress(totalFiles, completed, relativePath.path)
file.copyTo(destFile, overwrite = true)
totalSize += file.length()
completed++
}
}
val elapsed = System.currentTimeMillis()
logger.info { "Backup complete: $completed files copied to $destDir" }
return BackupResult.Success(
backupPath = destDir,
fileCount = completed,
sizeBytes = totalSize,
durationMs = 0, // caller can measure
)
}
private suspend fun backupZip(
wowDir: File,
sourceFolders: List<File>,
allFiles: List<File>,
backupDir: File,
timestamp: String,
totalFiles: Int,
): BackupResult {
val zipFile = File(backupDir, "$timestamp.zip")
var completed = 0
ZipOutputStream(FileOutputStream(zipFile).buffered()).use { zos ->
for (folder in sourceFolders) {
for (file in folder.walkTopDown().filter { it.isFile }) {
currentCoroutineContext().ensureActive()
val relativePath = file.relativeTo(wowDir).path
_progress.value = BackupProgress(totalFiles, completed, relativePath)
val entry = ZipEntry(relativePath)
entry.time = file.lastModified()
zos.putNextEntry(entry)
file.inputStream().buffered().use { it.copyTo(zos) }
zos.closeEntry()
completed++
}
}
}
logger.info { "Backup complete: $completed files compressed to $zipFile (${zipFile.length()} bytes)" }
return BackupResult.Success(
backupPath = zipFile,
fileCount = completed,
sizeBytes = zipFile.length(),
durationMs = 0,
)
}
}

View file

@ -0,0 +1,91 @@
package com.rukira.wowbackup.backup
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.datetime.LocalDateTime
import java.io.File
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
private val logger = KotlinLogging.logger {}
data class BackupEntry(
val path: File,
val timestamp: LocalDateTime,
val isCompressed: Boolean,
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")
/**
* Lists all existing backups in the backup directory, sorted newest first.
*/
fun listBackups(backupDir: File): List<BackupEntry> {
if (!backupDir.exists() || !backupDir.isDirectory) return emptyList()
val entries = backupDir.listFiles()?.mapNotNull { file ->
parseBackupEntry(file)
} ?: emptyList()
return entries.sortedByDescending { it.timestamp }
}
/**
* Deletes old backups that exceed the given max count.
*/
fun pruneOldBackups(backupDir: File, maxCount: Int) {
val backups = listBackups(backupDir)
if (backups.size <= maxCount) return
val toDelete = backups.drop(maxCount)
for (entry in toDelete) {
try {
if (entry.isCompressed) {
entry.path.delete()
} else {
entry.path.deleteRecursively()
}
logger.info { "Pruned old backup: ${entry.path.name}" }
} catch (e: Exception) {
logger.error(e) { "Failed to delete old backup: ${entry.path.name}" }
}
}
}
private fun parseBackupEntry(file: File): BackupEntry? {
val name = file.nameWithoutExtension
val isZip = file.extension.equals("zip", ignoreCase = true) && file.isFile
val isDir = file.isDirectory
if (!isZip && !isDir) return null
// Parse with java.time, then convert to kotlinx.datetime
val javaTimestamp = try {
java.time.LocalDateTime.parse(name, JAVA_TIMESTAMP_FORMAT)
} catch (e: DateTimeParseException) {
return null
}
val timestamp = LocalDateTime(
javaTimestamp.year, javaTimestamp.monthValue, javaTimestamp.dayOfMonth,
javaTimestamp.hour, javaTimestamp.minute, javaTimestamp.second,
)
val size = if (isZip) {
file.length()
} else {
file.walkTopDown().filter { it.isFile }.sumOf { it.length() }
}
return BackupEntry(
path = file,
timestamp = timestamp,
isCompressed = isZip,
sizeBytes = size,
)
}
}

View file

@ -0,0 +1,45 @@
package com.rukira.wowbackup.backup
import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.TrayState
import com.rukira.wowbackup.config.ConfigManager
import io.github.oshai.kotlinlogging.KotlinLogging
private val logger = KotlinLogging.logger {}
object BackupNotifier {
var trayState: TrayState? = null
fun notifyBackupStarted() {
send("Backup in progress...", Notification.Type.None)
}
fun notifyBackupComplete(fileCount: Int, sizeMb: String) {
send("Backup complete ($fileCount files, $sizeMb)", Notification.Type.None)
}
fun notifyBackupFailed(reason: String) {
send("Backup failed: $reason", Notification.Type.Error)
}
fun notifyBackupSkipped(reason: String) {
send("Backup skipped — $reason", Notification.Type.Warning)
}
private fun send(message: String, type: Notification.Type) {
if (!ConfigManager.config.value.notificationsEnabled) {
logger.debug { "Notification suppressed (disabled): $message" }
return
}
val tray = trayState
if (tray == null) {
logger.warn { "Cannot send notification, TrayState not set: $message" }
return
}
logger.info { "Notification: $message" }
tray.sendNotification(Notification("WoW Backup", message, type))
}
}

View file

@ -0,0 +1,182 @@
@file:OptIn(kotlin.time.ExperimentalTime::class)
package com.rukira.wowbackup.backup
import com.rukira.wowbackup.config.ConfigManager
import io.github.kevincianfarini.cardiologist.schedulePulse
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import java.io.File
import kotlin.time.Clock
private val logger = KotlinLogging.logger {}
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,
)
object BackupScheduler {
private val scope = CoroutineScope(Dispatchers.Default)
private var schedulerJob: Job? = null
private var pulseJob: Job? = null
private var backupJob: Job? = null
private val _state = MutableStateFlow(SchedulerState())
val state: StateFlow<SchedulerState> = _state.asStateFlow()
fun start() {
if (schedulerJob?.isActive == true) return
logger.info { "Backup scheduler started" }
schedulerJob = scope.launch {
// Collect progress from BackupEngine
launch {
BackupEngine.progress.collect { progress ->
_state.update { it.copy(currentProgress = progress) }
}
}
// React to config changes and reschedule the pulse
ConfigManager.config.collect { config ->
// Only cancel the pulse (the waiting), not a running backup
pulseJob?.cancel()
if (!config.isConfigured) {
_state.update { it.copy(nextBackupTime = null) }
return@collect
}
val tz = TimeZone.currentSystemDefault()
val targetTime = config.backupTimeOfDay
logger.info { "Scheduling daily backup at $targetTime ($tz)" }
updateNextBackupTime(tz)
pulseJob = launch {
Clock.System.schedulePulse(
atHour = targetTime.hour,
atMinute = targetTime.minute,
atSecond = 0,
timeZone = tz,
).beat { _ ->
// Read fresh config at execution time, not the stale closure value
val currentConfig = ConfigManager.config.value
val wowRunning = WoWProcessDetector.isWoWRunning()
_state.update { it.copy(isWoWRunning = wowRunning) }
if (wowRunning && !currentConfig.forceBackupWhileRunning) {
logger.info { "Backup time reached but WoW is running (force disabled), skipping" }
BackupNotifier.notifyBackupSkipped("WoW is running")
} else {
// Launch backup in a separate job so it survives pulse cancellation
// (e.g. if user changes config while backup is running)
backupJob = scope.launch { executeBackup() }
backupJob?.join()
}
updateNextBackupTime(tz)
}
}
}
}
}
fun stop() {
schedulerJob?.cancel()
pulseJob?.cancel()
backupJob?.cancel()
schedulerJob = null
pulseJob = null
backupJob = null
logger.info { "Backup scheduler stopped" }
}
fun triggerBackupNow() {
if (_state.value.isRunning) {
logger.info { "Backup already running, ignoring manual trigger" }
return
}
logger.info { "Manual backup triggered" }
backupJob = scope.launch {
executeBackup()
updateNextBackupTime(TimeZone.currentSystemDefault())
}
}
private suspend fun executeBackup() {
val config = ConfigManager.config.value
if (!config.isConfigured) return
_state.update { it.copy(isRunning = true) }
BackupNotifier.notifyBackupStarted()
val startTime = System.currentTimeMillis()
val result = BackupEngine.runBackup(config)
val duration = System.currentTimeMillis() - startTime
val tz = TimeZone.currentSystemDefault()
val now = now(tz)
_state.update {
it.copy(
isRunning = false,
currentProgress = null,
lastBackupTime = now,
lastBackupResult = when (result) {
is BackupResult.Success -> result.copy(durationMs = duration)
is BackupResult.Failure -> result
},
)
}
when (result) {
is BackupResult.Success -> {
val sizeMb = "%.1f MB".format(result.sizeBytes / (1024.0 * 1024.0))
BackupNotifier.notifyBackupComplete(result.fileCount, sizeMb)
val backupDir = File(config.backupPath!!)
BackupHistory.pruneOldBackups(backupDir, config.backupHistoryCount)
}
is BackupResult.Failure -> {
BackupNotifier.notifyBackupFailed(result.reason)
}
}
}
private fun updateNextBackupTime(tz: TimeZone) {
val config = ConfigManager.config.value
val targetTime = config.backupTimeOfDay
val now = now(tz)
val today = now.date
val todayTarget = LocalDateTime(today, targetTime)
val next = if (now < todayTarget) {
todayTarget
} else {
val tomorrow = today.plus(1, DateTimeUnit.DAY)
LocalDateTime(tomorrow, targetTime)
}
_state.update { it.copy(nextBackupTime = next) }
}
private fun now(tz: TimeZone): LocalDateTime = Clock.System.now().toLocalDateTime(tz)
}

View file

@ -0,0 +1,33 @@
package com.rukira.wowbackup.backup
import io.github.oshai.kotlinlogging.KotlinLogging
private val logger = KotlinLogging.logger {}
object WoWProcessDetector {
private val WOW_PROCESS_NAMES = listOf(
"world of warcraft",
"wow.exe",
"wow-64.exe",
"wow.app",
)
fun isWoWRunning(): Boolean {
return try {
val running = ProcessHandle.allProcesses()
.filter { it.isAlive }
.anyMatch { handle ->
val command = handle.info().command().orElse("").lowercase()
WOW_PROCESS_NAMES.any { name -> command.contains(name) }
}
if (running) {
logger.debug { "WoW process detected as running" }
}
running
} catch (e: Exception) {
logger.warn(e) { "Failed to check WoW process status" }
false
}
}
}

View file

@ -1,25 +1,13 @@
package com.rukira.wowbackup.config
import kotlinx.serialization.KSerializer
import kotlinx.datetime.LocalTime
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 backupTimeOfDay: LocalTime = LocalTime(hour = 3, minute = 0),
val backupHistoryCount: Int = 5,
val forceBackupWhileRunning: Boolean = false,
val backupWtf: Boolean = true,

View file

@ -4,15 +4,26 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Tray
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
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.WoWLocations
import com.rukira.wowbackup.ui.Screen
import com.rukira.wowbackup.ui.trayIconPainter
import io.github.oshai.kotlinlogging.KotlinLogging
import java.awt.GraphicsEnvironment
private val logger = KotlinLogging.logger {}
@ -30,11 +41,19 @@ fun main() {
val startScreen = if (ConfigManager.isConfigured) Screen.STATUS else Screen.CONFIG
// Start backup scheduler
BackupScheduler.start()
application {
var isWindowVisible by remember { mutableStateOf(true) }
var currentScreen by remember { mutableStateOf(startScreen) }
val trayState = rememberTrayState()
// Wire up notifications
BackupNotifier.trayState = trayState
Tray(
state = trayState,
icon = trayIconPainter(),
tooltip = "WoW Backup",
onAction = { isWindowVisible = true },
@ -48,14 +67,30 @@ fun main() {
isWindowVisible = true
})
Separator()
Item("Quit", onClick = ::exitApplication)
Item("Backup Now", onClick = {
BackupScheduler.triggerBackupNow()
})
Separator()
Item("Quit", onClick = {
BackupScheduler.stop()
exitApplication()
})
},
)
if (isWindowVisible) {
val windowState = remember(isWindowVisible) {
WindowState(
size = DpSize(480.dp, 620.dp),
position = trayAlignedPosition(),
)
}
Window(
onCloseRequest = { isWindowVisible = false },
title = "WoW Backup",
state = windowState,
resizable = true,
) {
App(
currentScreen = currentScreen,
@ -65,3 +100,29 @@ fun main() {
}
}
}
/**
* Positions the window near the system tray area:
* - macOS: top-right (menu bar is at the top, tray icons on the right)
* - Windows: bottom-right (taskbar tray is at the bottom-right)
* - Other: top-right as fallback
*/
private fun trayAlignedPosition(): WindowPosition {
val ge = GraphicsEnvironment.getLocalGraphicsEnvironment()
val screenBounds = ge.defaultScreenDevice.defaultConfiguration.bounds
val insets = java.awt.Toolkit.getDefaultToolkit().getScreenInsets(ge.defaultScreenDevice.defaultConfiguration)
val windowWidth = 480
val windowHeight = 620
val padding = 8
val x = screenBounds.width - windowWidth - insets.right - padding
val y = when (Platform.current) {
OS.Mac -> insets.top + padding // just below the menu bar
OS.Windows -> screenBounds.height - windowHeight - insets.bottom - padding // above the taskbar
else -> insets.top + padding
}
return WindowPosition.Absolute(x.dp, y.dp)
}

View file

@ -17,7 +17,7 @@ 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
import kotlinx.datetime.LocalTime
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -37,7 +37,7 @@ fun TimePicker(
selectedValue = value.hour,
options = (0..23).toList(),
format = { "%02d".format(it) },
onSelect = { onValueChange(value.withHour(it)) },
onSelect = { onValueChange(LocalTime(it, value.minute)) },
modifier = Modifier.weight(1f),
)
@ -49,7 +49,7 @@ fun TimePicker(
selectedValue = value.minute,
options = listOf(0, 15, 30, 45),
format = { "%02d".format(it) },
onSelect = { onValueChange(value.withMinute(it)) },
onSelect = { onValueChange(LocalTime(value.hour, it)) },
modifier = Modifier.weight(1f),
)
}

View file

@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import com.rukira.wowbackup.platform.WoWValidationResult
import java.io.File
import java.time.LocalTime
import kotlinx.datetime.LocalTime
private val logger = KotlinLogging.logger {}