Backup process
This commit is contained in:
parent
4b3c512a9d
commit
171bf78be8
11 changed files with 596 additions and 19 deletions
|
|
@ -24,7 +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.datetime)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.cardiologist)
|
||||||
implementation(libs.kotlin.logging.jvm)
|
implementation(libs.kotlin.logging.jvm)
|
||||||
implementation(libs.logback.classic)
|
implementation(libs.logback.classic)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,13 @@
|
||||||
package com.rukira.wowbackup.config
|
package com.rukira.wowbackup.config
|
||||||
|
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.serialization.Serializable
|
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
|
@Serializable
|
||||||
data class AppConfig(
|
data class AppConfig(
|
||||||
val wowInstallPath: String? = null,
|
val wowInstallPath: String? = null,
|
||||||
val backupPath: String? = null,
|
val backupPath: String? = null,
|
||||||
@Serializable(with = LocalTimeSerializer::class)
|
val backupTimeOfDay: LocalTime = LocalTime(hour = 3, minute = 0),
|
||||||
val backupTimeOfDay: LocalTime = LocalTime.of(3, 0),
|
|
||||||
val backupHistoryCount: Int = 5,
|
val backupHistoryCount: Int = 5,
|
||||||
val forceBackupWhileRunning: Boolean = false,
|
val forceBackupWhileRunning: Boolean = false,
|
||||||
val backupWtf: Boolean = true,
|
val backupWtf: Boolean = true,
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,26 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.Tray
|
||||||
import androidx.compose.ui.window.Window
|
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.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.config.ConfigManager
|
||||||
import com.rukira.wowbackup.logging.LoggingSetup
|
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.platform.WoWLocations
|
||||||
import com.rukira.wowbackup.ui.Screen
|
import com.rukira.wowbackup.ui.Screen
|
||||||
import com.rukira.wowbackup.ui.trayIconPainter
|
import com.rukira.wowbackup.ui.trayIconPainter
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import java.awt.GraphicsEnvironment
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
|
@ -30,11 +41,19 @@ fun main() {
|
||||||
|
|
||||||
val startScreen = if (ConfigManager.isConfigured) Screen.STATUS else Screen.CONFIG
|
val startScreen = if (ConfigManager.isConfigured) Screen.STATUS else Screen.CONFIG
|
||||||
|
|
||||||
|
// Start backup scheduler
|
||||||
|
BackupScheduler.start()
|
||||||
|
|
||||||
application {
|
application {
|
||||||
var isWindowVisible by remember { mutableStateOf(true) }
|
var isWindowVisible by remember { mutableStateOf(true) }
|
||||||
var currentScreen by remember { mutableStateOf(startScreen) }
|
var currentScreen by remember { mutableStateOf(startScreen) }
|
||||||
|
val trayState = rememberTrayState()
|
||||||
|
|
||||||
|
// Wire up notifications
|
||||||
|
BackupNotifier.trayState = trayState
|
||||||
|
|
||||||
Tray(
|
Tray(
|
||||||
|
state = trayState,
|
||||||
icon = trayIconPainter(),
|
icon = trayIconPainter(),
|
||||||
tooltip = "WoW Backup",
|
tooltip = "WoW Backup",
|
||||||
onAction = { isWindowVisible = true },
|
onAction = { isWindowVisible = true },
|
||||||
|
|
@ -48,14 +67,30 @@ fun main() {
|
||||||
isWindowVisible = true
|
isWindowVisible = true
|
||||||
})
|
})
|
||||||
Separator()
|
Separator()
|
||||||
Item("Quit", onClick = ::exitApplication)
|
Item("Backup Now", onClick = {
|
||||||
|
BackupScheduler.triggerBackupNow()
|
||||||
|
})
|
||||||
|
Separator()
|
||||||
|
Item("Quit", onClick = {
|
||||||
|
BackupScheduler.stop()
|
||||||
|
exitApplication()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isWindowVisible) {
|
if (isWindowVisible) {
|
||||||
|
val windowState = remember(isWindowVisible) {
|
||||||
|
WindowState(
|
||||||
|
size = DpSize(480.dp, 620.dp),
|
||||||
|
position = trayAlignedPosition(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = { isWindowVisible = false },
|
onCloseRequest = { isWindowVisible = false },
|
||||||
title = "WoW Backup",
|
title = "WoW Backup",
|
||||||
|
state = windowState,
|
||||||
|
resizable = true,
|
||||||
) {
|
) {
|
||||||
App(
|
App(
|
||||||
currentScreen = currentScreen,
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -37,7 +37,7 @@ fun TimePicker(
|
||||||
selectedValue = value.hour,
|
selectedValue = value.hour,
|
||||||
options = (0..23).toList(),
|
options = (0..23).toList(),
|
||||||
format = { "%02d".format(it) },
|
format = { "%02d".format(it) },
|
||||||
onSelect = { onValueChange(value.withHour(it)) },
|
onSelect = { onValueChange(LocalTime(it, value.minute)) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@ fun TimePicker(
|
||||||
selectedValue = value.minute,
|
selectedValue = value.minute,
|
||||||
options = listOf(0, 15, 30, 45),
|
options = listOf(0, 15, 30, 45),
|
||||||
format = { "%02d".format(it) },
|
format = { "%02d".format(it) },
|
||||||
onSelect = { onValueChange(value.withMinute(it)) },
|
onSelect = { onValueChange(LocalTime(value.hour, it)) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import com.rukira.wowbackup.platform.WoWValidationResult
|
import com.rukira.wowbackup.platform.WoWValidationResult
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ 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-datetime = "0.6.2"
|
||||||
kotlinx-serialization = "1.10.0"
|
kotlinx-serialization = "1.10.0"
|
||||||
|
cardiologist = "0.8.0"
|
||||||
kotlin-logging = "7.0.3"
|
kotlin-logging = "7.0.3"
|
||||||
logback = "1.5.18"
|
logback = "1.5.18"
|
||||||
|
|
||||||
|
|
@ -11,7 +13,9 @@ logback = "1.5.18"
|
||||||
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-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
|
cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }
|
||||||
kotlin-logging-jvm = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "kotlin-logging" }
|
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" }
|
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue