Backup process
This commit is contained in:
parent
4b3c512a9d
commit
171bf78be8
11 changed files with 596 additions and 19 deletions
|
|
@ -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
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue