wow-backup/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupScheduler.kt
2026-03-04 14:19:19 +00:00

182 lines
6.3 KiB
Kotlin

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