Compare commits

...

7 commits

Author SHA1 Message Date
Rukira
195cbe8f7c feat(update): add update detection and notification on status screen (WB-008)
Some checks are pending
Release Pipeline / Bump Version & Create Tag (push) Waiting to run
Release Pipeline / Package Linux (DEB) (push) Blocked by required conditions
Release Pipeline / Package macOS (DMG) (push) Blocked by required conditions
Release Pipeline / Package Windows (MSI) (push) Blocked by required conditions
Release Pipeline / Create GitHub & Forgejo Releases (push) Blocked by required conditions
Co-authored-by: Junie <junie@jetbrains.com>
2026-08-25 10:30:52 +01:00
Rukira
d6a53dcb47 Fixing versioning 2026-08-25 10:21:39 +01:00
Rukira
b131adfad0 Cleaning up install instructions 2026-08-25 10:19:13 +01:00
Rukira
49ce1b17ac Fixing versioning 2026-08-25 10:17:38 +01:00
Rukira
7b555b7c9b Unlicense 2026-08-25 10:10:15 +01:00
Rukira
42a1b47348 License and readme updates 2026-08-25 10:09:41 +01:00
Rukira
8b39d0345b Fixing MacOS build issue. Improving documentation. 2026-08-25 09:40:28 +01:00
16 changed files with 688 additions and 87 deletions

View file

@ -1,22 +1,18 @@
# Installing WoW Backup # Installing WoW Backup
This guide provides step-by-step instructions for installing and running **WoW Backup** on macOS, Windows, and Linux.
---
## Download ## Download
Download the appropriate installer for your platform from the **[GitHub Releases](https://github.com/rukira/wow-backup/releases)** page: Download the appropriate installer for your platform from the **[latest release](https://git.asarius.site/rukira/wow-backup/releases)**:
| Platform | Package Format | Installer File Pattern | | Platform | Package Format | Installer File Pattern |
|---|---|---| |--------------------------|---|---|
| **macOS** | DMG Disk Image | `WoW Backup-<version>.dmg` | | **macOS** | DMG Disk Image | `WoW Backup-<version>.dmg` |
| **Windows** | Windows Installer (MSI) | `WoW Backup-<version>.msi` | | **Windows** | Windows Installer (MSI) | `WoW Backup-<version>.msi` |
| **Linux** | Debian Package (DEB) | `wow-backup_<version>_amd64.deb` | | **Linux** (Experimental) | Debian Package (DEB) | `wow-backup_<version>_amd64.deb` |
--- ---
## macOS Installation ## macOS
1. Download the `.dmg` file from the latest release. 1. Download the `.dmg` file from the latest release.
2. Double-click the `.dmg` file to mount the disk image. 2. Double-click the `.dmg` file to mount the disk image.
@ -27,20 +23,14 @@ Download the appropriate installer for your platform from the **[GitHub Releases
Because WoW Backup is distributed without Apple Developer ID code-signing notarization, macOS Gatekeeper may show a warning on first launch (*"WoW Backup cannot be opened because the developer cannot be verified"* or *"Apple could not verify that WoW Backup is free of malware"*). Because WoW Backup is distributed without Apple Developer ID code-signing notarization, macOS Gatekeeper may show a warning on first launch (*"WoW Backup cannot be opened because the developer cannot be verified"* or *"Apple could not verify that WoW Backup is free of malware"*).
To launch the app for the first time: To launch the app for the first time:
1. Open **System Settings**
- **Option A (Finder)**: 2. Navigate to **Security & Privacy**.
1. Open Finder and navigate to `/Applications`. 3. Click **Open Anyway** under **Allow apps downloaded from**.
2. Right-click (or Control-click) on **WoW Backup.app** and choose **Open**. 4. Restart WoW Backup.
3. In the security popup, click **Open**.
- **Option B (Terminal)**:
Run the following command in Terminal to clear the quarantine attribute:
```shell
xattr -d com.apple.quarantine "/Applications/WoW Backup.app"
```
--- ---
## Windows Installation ## Windows
1. Download the `.msi` installer from the latest release. 1. Download the `.msi` installer from the latest release.
2. Double-click the `.msi` file to start the installation wizard. 2. Double-click the `.msi` file to start the installation wizard.
@ -54,7 +44,7 @@ If Windows Defender SmartScreen flags the installer (*"Windows protected your PC
--- ---
## Linux Installation ## Linux (Experimental)
1. Download the `.deb` package from the latest release. 1. Download the `.deb` package from the latest release.
2. Open your terminal in the download folder and install the package: 2. Open your terminal in the download folder and install the package:
@ -68,17 +58,3 @@ If Windows Defender SmartScreen flags the installer (*"Windows protected your PC
``` ```
3. Launch **WoW Backup** from your desktop application launcher or run `wowbackup` in the terminal. 3. Launch **WoW Backup** from your desktop application launcher or run `wowbackup` in the terminal.
---
## First-Time Setup & Overview
Once started, WoW Backup runs discreetly in the system tray / menu bar:
1. **Accessing Dashboard**: Click the tray icon in the macOS menu bar or Windows/Linux system tray to open the application window.
2. **Settings Configuration**:
- Navigate to **Settings** (gear icon).
- Set your **World of Warcraft Installation Folder** (e.g. `_retail_` or `_classic_`).
- Select your **Backup Destination Folder**.
- Set your preferred **Daily Backup Time**, **Retention Policy** (number of backups to keep), and **Compression Mode** (`ZIP` or folder replication).
- Optionally enable **Launch at Startup** so backups occur automatically without manual intervention.
3. **Lifecycle**: Closing the main window hides it to the tray while keeping the scheduler running. To quit the application completely, select **Quit** from the tray menu.

24
LICENSE Normal file
View file

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

View file

@ -1,22 +1,32 @@
# WoW Backup # WoW Backup
A lightweight, native-feeling desktop application built with Kotlin and Compose Multiplatform to automatically backup and restore World of Warcraft configuration data (WTF and Interface directories) on macOS and Windows. A lightweight, native-feeling desktop application to automatically backup World of Warcraft configuration data (WTF and Interface directories).
## Overview World of Warcraft stores all UI configurations, macros, keybindings, and addon data in the `WTF/` and `Interface/` folders. Losing this data due to game corruption, accidental deletion, or addon errors can mean losing years of customized setups. **WoW Backup** runs discreetly in the system tray, and automatically performs scheduled, timestamped backups (optionally compressed as ZIP archives).
World of Warcraft stores all UI configurations, macros, keybindings, and addon saved variables in the `WTF/` and `Interface/` folders. Losing this data due to game corruption, accidental deletion, or addon errors can mean losing years of customized setups. **WoW Backup** runs discreetly in the system tray, automatically creates scheduled, timestamped backups (optionally compressed as ZIP archives), and will provide seamless one-click restoration. ## Installation & Download
Pre-built binaries for **macOS**, **Windows**, and **Linux** are provided on each release.
👉 **See [here](INSTALL.md)** for download links and step-by-step installation instructions.
## Key Features ## Key Features
- 🛡️ **System Tray Native Life Cycle**: Lives in the menu bar / system tray with custom template icons matching light/dark OS appearances. Closing the window hides to tray; quitting is explicit.
- ⚙️ **Smart Auto-Configuration**: Automatically detects standard WoW install locations (supporting retail and classic directories) and validates folder permissions. - ⚙️ **Smart Auto-Configuration**: Automatically detects standard WoW install locations (supporting retail and classic directories) and validates folder permissions.
- ⏰ **Automated & Scheduled Backups**: Background scheduler powered by Cardiologist ensures dependable daily backups at a user-defined time. - ⏰ **Automated & Scheduled Backups**: Background scheduler ensures regular backups at a user-defined time.
- 📦 **Compression & History Management**: Supports both folder replication and ZIP compression, with automatic pruning of backups beyond the configured retention limit (130 backups). - 📦 **Compression & History Management**: Supports both folder replication and ZIP compression, with automatic pruning of backups beyond the configured limit.
- 🎮 **Game State Detection**: Checks for active WoW processes before running backups to prevent file-locking conflicts, with optional "force backup" override and safety confirmation. - 🎮 **Game State Detection**: Checks for running WoW processes before running backups to prevent file-locking conflicts.
- 🔔 **Native OS Notifications**: Notifies the user when backups start, succeed, fail, or are skipped due to game execution. - 🔔 **Native OS Notifications**: Notifies the user when backups start, succeed, fail, or are skipped.
- 🚀 **Launch at Startup**: Integrated with macOS LaunchAgents (`~/Library/LaunchAgents/`) and Windows Registry (`HKCU\...\Run`). - 🚀 **Launch at Startup**: Integrated with macOS LaunchAgents (`~/Library/LaunchAgents/`) and Windows Registry (`HKCU\...\Run`).
- 🎨 **Modern Theming & Customization**: Dynamic Material 3 theming supporting System/Light/Dark modes and configurable accent colors (via MaterialKolor). - 🎨 **Theming & Customization**: Dynamic theming supporting Light/Dark mode, with configurable accent colors.
- 🔄 **Restore System (In Progress)**: Interactive restore interface to safely roll back WTF and Interface configurations.
---
## Application Data Locations
The app uses a configuration file to store user preferences. This file and runtime logs are stored in the following locations:
- **macOS**: `~/Library/Application Support/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
- **Windows**: `%APPDATA%\WoWBackup\` (Logs: `...\logs\wowbackup.log`)
- **Linux**: `~/.config/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
--- ---
@ -24,22 +34,14 @@ World of Warcraft stores all UI configurations, macros, keybindings, and addon s
- **Language & Runtime**: Kotlin Multiplatform targeting Desktop (JVM, Java 17+) - **Language & Runtime**: Kotlin Multiplatform targeting Desktop (JVM, Java 17+)
- **UI Framework**: JetBrains Compose Multiplatform with Material 3 Design - **UI Framework**: JetBrains Compose Multiplatform with Material 3 Design
- **Architecture**: MVI / MVVM with Kotlin Coroutines and StateFlow - **Architecture**: MVVM with Kotlin Coroutines
- **Scheduling**: `io.github.kevincianfarini.cardiologist` for drift-free daily execution - **Scheduling**: `io.github.kevincianfarini.cardiologist`
- **Logging**: `logback-classic` + `kotlin-logging-jvm` with daily rotation and size archiving in app data directories - **Logging**: `logback-classic` + `kotlin-logging-jvm` with daily rotation and archiving
- **Serialization**: `kotlinx-serialization-json` for typed JSON configuration persistence - **Serialization**: Settings stored in a JSON file using `kotlinx-serialization-json`
- **Theming**: `com.materialkolor:material-kolor` for dynamic palette generation - **Theming**: `com.materialkolor:material-kolor` for dynamic palette generation
--- ---
## Installation & Download
Pre-built binaries for macOS, Windows, and Linux are automatically packaged and published on each release.
👉 **See [INSTALL.md](INSTALL.md)** for download links, step-by-step installation instructions, Gatekeeper/SmartScreen bypass guidance, and initial setup notes.
---
## Build & Run ## Build & Run
### Prerequisites ### Prerequisites
@ -71,22 +73,6 @@ Pre-built binaries for macOS, Windows, and Linux are automatically packaged and
--- ---
## Feature Implementation Status ## License
| Feature | Plan Document | Status | Description | This project is released into the public domain. See the [LICENSE](LICENSE) file or [unlicense.org](https://unlicense.org/) for details.
|---|---|---|---|
| **0. Foundation** | `docs/plans/feature-0-foundation.md` | ✅ **Completed** | Architecture, config persistence, logging, platform abstractions |
| **1. System Tray** | `docs/plans/feature-1-system-tray.md` | ✅ **Completed** | Tray icon, menu, tray-first lifecycle, window alignment |
| **2. Configuration** | `docs/plans/feature-2-configuration.md` | ✅ **Completed** | Full settings UI, path validation, native file dialogs, startup manager |
| **3. Backup Engine** | `docs/plans/feature-3-backup.md` | ✅ **Completed** | Parallel copy, ZIP compression, scheduler, history pruning, notifications |
| **4. Status Screen** | `docs/plans/feature-4-status.md` | ✅ **Completed** | Live status dashboard, progress indicator, manual backup trigger, shortcuts |
| **5. Restore Screen** | `docs/plans/feature-5-restore.md` | 🚧 **Pending** | Backup list browser, ZIP extraction, confirmation flow, safety checks |
| **6. Release Pipeline** | `docs/plans/feature-6-release-pipeline.md` | ✅ **Completed** | GitHub Actions multi-platform release pipeline & packaging automation |
---
## Application Data Locations
- **macOS**: `~/Library/Application Support/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
- **Windows**: `%APPDATA%\WoWBackup\` (Logs: `...\logs\wowbackup.log`)
- **Linux**: `~/.config/WoWBackup/` (Logs: `.../logs/wowbackup.log`)

View file

@ -31,16 +31,21 @@ kotlin {
implementation(libs.logback.classic) implementation(libs.logback.classic)
implementation(libs.material.kolor) implementation(libs.material.kolor)
} }
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutinesTest)
}
} }
} }
val appVersion = (project.findProperty("appVersion") as? String)?.takeIf { it.isNotBlank() } ?: "1.0.0" val appVersion = providers.gradleProperty("appVersion").orNull?.takeIf { it.isNotBlank() } ?: "0.0.1"
val generatedSrcDir = layout.buildDirectory.dir("generated/src/jvmMain/kotlin") val generatedSrcDir = layout.buildDirectory.dir("generated/src/jvmMain/kotlin")
val generateBuildConfig by tasks.registering { val generateBuildConfig by tasks.registering {
val outputDir = generatedSrcDir val outputDir = generatedSrcDir
val version = appVersion val version = appVersion
inputs.property("version", version)
outputs.dir(outputDir) outputs.dir(outputDir)
doLast { doLast {
val dir = outputDir.get().asFile.resolve("com/rukira/wowbackup") val dir = outputDir.get().asFile.resolve("com/rukira/wowbackup")
@ -72,6 +77,8 @@ compose.desktop {
packageName = "WoW Backup" packageName = "WoW Backup"
packageVersion = appVersion packageVersion = appVersion
modules("java.naming", "java.sql", "java.instrument", "jdk.unsupported")
macOS { macOS {
bundleID = "com.rukira.wowbackup" bundleID = "com.rukira.wowbackup"
iconFile.set(project.file("src/jvmMain/resources/icon.icns")) iconFile.set(project.file("src/jvmMain/resources/icon.icns"))

View file

@ -12,7 +12,12 @@ import java.io.File
object LoggingSetup { object LoggingSetup {
private var initialized = false
fun init() { fun init() {
if (initialized) return
initialized = true
val logDir = AppDirectories.logsDir val logDir = AppDirectories.logsDir
val logFile = File(logDir, "wowbackup.log") val logFile = File(logDir, "wowbackup.log")
@ -26,7 +31,7 @@ object LoggingSetup {
val rollingPolicy = SizeAndTimeBasedRollingPolicy<ILoggingEvent>().apply { val rollingPolicy = SizeAndTimeBasedRollingPolicy<ILoggingEvent>().apply {
this.context = context this.context = context
fileNamePattern = "${logDir}/wowbackup.%d{yyyy-MM-dd}.%i.log.gz" fileNamePattern = "${logDir.absolutePath}/wowbackup.%d{yyyy-MM-dd}.%i.log.gz"
setMaxFileSize(FileSize.valueOf("10MB")) setMaxFileSize(FileSize.valueOf("10MB"))
maxHistory = 30 maxHistory = 30
setTotalSizeCap(FileSize.valueOf("100MB")) setTotalSizeCap(FileSize.valueOf("100MB"))
@ -46,5 +51,10 @@ object LoggingSetup {
val rootLogger = context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME) val rootLogger = context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)
rootLogger.addAppender(fileAppender) rootLogger.addAppender(fileAppender)
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
val uncaughtLogger = LoggerFactory.getLogger("UncaughtExceptionHandler")
uncaughtLogger.error("Uncaught exception on thread [${thread.name}]", throwable)
}
} }
} }

View file

@ -3,6 +3,7 @@ package com.rukira.wowbackup.platform
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import java.awt.Desktop import java.awt.Desktop
import java.io.File import java.io.File
import java.net.URI
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -15,4 +16,16 @@ object DesktopActions {
logger.error(e) { "Failed to open folder: $path" } logger.error(e) { "Failed to open folder: $path" }
} }
} }
fun openUrl(url: String) {
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(URI(url))
} else {
logger.warn { "Desktop browsing is not supported on this platform" }
}
} catch (e: Exception) {
logger.error(e) { "Failed to open URL: $url" }
}
}
} }

View file

@ -2,6 +2,7 @@ package com.rukira.wowbackup.ui.status
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -214,12 +215,33 @@ fun StatusScreen(
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Text( Row(
"WoW Backup v${BuildConfig.VERSION}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = androidx.compose.ui.text.style.TextAlign.Center, horizontalArrangement = Arrangement.Center,
) verticalAlignment = Alignment.CenterVertically,
) {
Text(
"WoW Backup v${BuildConfig.VERSION}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val update = uiState.updateInfo
if (update != null) {
Spacer(Modifier.width(8.dp))
Text(
"• Update available (v${update.latestVersion})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(6.dp))
Button(
onClick = { DesktopActions.openUrl(update.releaseUrl) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
modifier = Modifier.height(26.dp),
) {
Text("Download", style = MaterialTheme.typography.labelSmall)
}
}
}
} }
} }

View file

@ -7,13 +7,18 @@ import com.rukira.wowbackup.backup.BackupProgress
import com.rukira.wowbackup.backup.BackupResult import com.rukira.wowbackup.backup.BackupResult
import com.rukira.wowbackup.backup.BackupScheduler import com.rukira.wowbackup.backup.BackupScheduler
import com.rukira.wowbackup.config.ConfigManager import com.rukira.wowbackup.config.ConfigManager
import com.rukira.wowbackup.update.UpdateChecker
import com.rukira.wowbackup.update.UpdateInfo
import com.rukira.wowbackup.update.UpdateService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@ -32,10 +37,25 @@ data class StatusUiState(
val backupProgress: BackupProgress? = null, val backupProgress: BackupProgress? = null,
val totalBackups: Int = 0, val totalBackups: Int = 0,
val backupPath: String? = null, val backupPath: String? = null,
val updateInfo: UpdateInfo? = null,
) )
@OptIn(kotlin.time.ExperimentalTime::class) @OptIn(kotlin.time.ExperimentalTime::class)
class StatusViewModel : ViewModel() { class StatusViewModel(
private val updateService: UpdateService = UpdateChecker(),
) : ViewModel() {
private val updateInfo = MutableStateFlow<UpdateInfo?>(null)
init {
viewModelScope.launch(Dispatchers.IO) {
try {
updateInfo.value = updateService.checkForUpdate()
} catch (e: Exception) {
// Silently handle any uncaught exception
}
}
}
// Only recomputes when backupPath or lastBackupResult changes — not on every progress tick // Only recomputes when backupPath or lastBackupResult changes — not on every progress tick
private val totalBackups = combine( private val totalBackups = combine(
@ -53,7 +73,8 @@ class StatusViewModel : ViewModel() {
ConfigManager.config, ConfigManager.config,
BackupScheduler.state, BackupScheduler.state,
totalBackups, totalBackups,
) { config, scheduler, backupCount -> updateInfo,
) { config, scheduler, backupCount, update ->
StatusUiState( StatusUiState(
isConfigured = config.isConfigured, isConfigured = config.isConfigured,
lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) }, lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) },
@ -66,6 +87,7 @@ class StatusViewModel : ViewModel() {
backupProgress = scheduler.currentProgress, backupProgress = scheduler.currentProgress,
totalBackups = backupCount, totalBackups = backupCount,
backupPath = config.backupPath, backupPath = config.backupPath,
updateInfo = update,
) )
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState()) }.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState())

View file

@ -0,0 +1,52 @@
package com.rukira.wowbackup.update
data class SemVer(
val major: Int,
val minor: Int = 0,
val patch: Int = 0,
val preRelease: String? = null,
) : Comparable<SemVer> {
override fun compareTo(other: SemVer): Int {
if (this.major != other.major) return this.major.compareTo(other.major)
if (this.minor != other.minor) return this.minor.compareTo(other.minor)
if (this.patch != other.patch) return this.patch.compareTo(other.patch)
// A version without a pre-release is greater than one with a pre-release
return when {
this.preRelease == null && other.preRelease == null -> 0
this.preRelease == null && other.preRelease != null -> 1
this.preRelease != null && other.preRelease == null -> -1
else -> this.preRelease!!.compareTo(other.preRelease!!)
}
}
fun isNewerThan(other: SemVer): Boolean = this > other
override fun toString(): String {
val base = "$major.$minor.$patch"
return if (preRelease != null) "$base-$preRelease" else base
}
companion object {
fun parseOrNull(version: String?): SemVer? {
if (version.isNullOrBlank()) return null
val cleaned = version.trim().removePrefix("v").removePrefix("V").trim()
if (cleaned.isEmpty()) return null
val parts = cleaned.split("-", limit = 2)
val core = parts[0]
val preRelease = parts.getOrNull(1)?.takeIf { it.isNotBlank() }
val numParts = core.split(".")
if (numParts.isEmpty() || numParts.size > 3) return null
val major = numParts[0].toIntOrNull() ?: return null
val minor = if (numParts.size > 1) numParts[1].toIntOrNull() ?: return null else 0
val patch = if (numParts.size > 2) numParts[2].toIntOrNull() ?: return null else 0
return SemVer(major = major, minor = minor, patch = patch, preRelease = preRelease)
}
}
}

View file

@ -0,0 +1,102 @@
package com.rukira.wowbackup.update
import com.rukira.wowbackup.BuildConfig
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
private val logger = KotlinLogging.logger {}
@Serializable
internal data class ReleaseDto(
@SerialName("tag_name") val tagName: String = "",
val name: String? = null,
@SerialName("html_url") val htmlUrl: String? = null,
)
data class UpdateInfo(
val latestVersion: String,
val releaseUrl: String,
)
interface UpdateService {
suspend fun checkForUpdate(currentVersion: String = BuildConfig.VERSION): UpdateInfo?
}
class UpdateChecker(
private val apiUrl: String = DEFAULT_API_URL,
private val fallbackUrl: String = DEFAULT_FALLBACK_URL,
private val httpClient: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build(),
) : UpdateService {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo? = withContext(Dispatchers.IO) {
try {
val request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl))
.timeout(Duration.ofSeconds(5))
.header("Accept", "application/json")
.header("User-Agent", "WoWBackup/${BuildConfig.VERSION}")
.GET()
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() != 200) {
logger.warn { "Update check received non-200 status code: ${response.statusCode()}" }
return@withContext null
}
val body = response.body()
if (body.isNullOrBlank()) {
logger.warn { "Update check received empty response body" }
return@withContext null
}
val release = json.decodeFromString<ReleaseDto>(body)
val remoteSemVer = SemVer.parseOrNull(release.tagName) ?: run {
logger.warn { "Failed to parse remote release tag: '${release.tagName}'" }
return@withContext null
}
val currentSemVer = SemVer.parseOrNull(currentVersion) ?: run {
logger.warn { "Failed to parse current version: '$currentVersion'" }
return@withContext null
}
if (remoteSemVer.isNewerThan(currentSemVer)) {
val releaseUrl = release.htmlUrl?.takeIf { it.isNotBlank() } ?: fallbackUrl
val cleanTag = release.tagName.trim().removePrefix("v").removePrefix("V")
logger.info { "New update available: v$cleanTag (current: v$currentVersion). URL: $releaseUrl" }
UpdateInfo(
latestVersion = cleanTag,
releaseUrl = releaseUrl,
)
} else {
logger.debug { "App is up to date: current=$currentVersion, remote=${release.tagName}" }
null
}
} catch (e: Exception) {
logger.warn(e) { "Failed to check for updates: ${e.message}" }
null
}
}
companion object {
const val DEFAULT_API_URL = "https://git.asarius.site/api/v1/repos/rukira/wow-backup/releases/latest"
const val DEFAULT_FALLBACK_URL = "https://git.asarius.site/rukira/wow-backup/releases"
}
}

View file

@ -0,0 +1,11 @@
package com.rukira.wowbackup
import kotlin.test.Test
import kotlin.test.assertTrue
class BuildConfigTest {
@Test
fun testBuildConfigVersionIsNotEmpty() {
assertTrue(BuildConfig.VERSION.isNotBlank(), "BuildConfig.VERSION should not be blank")
}
}

View file

@ -0,0 +1,39 @@
package com.rukira.wowbackup.logging
import com.rukira.wowbackup.platform.AppDirectories
import io.github.oshai.kotlinlogging.KotlinLogging
import java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
class LoggingSetupTest {
private val testLogger = KotlinLogging.logger {}
@Test
fun testLoggingSetupInitializesAndWritesToFile() {
LoggingSetup.init()
val logsDir = AppDirectories.logsDir
assertTrue(logsDir.exists(), "Logs directory should exist")
assertTrue(logsDir.isDirectory, "Logs directory should be a directory")
val testMessage = "Test log entry for unit verification - ${System.currentTimeMillis()}"
testLogger.info { testMessage }
val logFile = File(logsDir, "wowbackup.log")
assertTrue(logFile.exists(), "wowbackup.log file should exist")
val content = logFile.readText()
assertTrue(content.contains(testMessage), "wowbackup.log should contain the logged message")
}
@Test
fun testAppDirectoriesLogsDir() {
val appDataDir = AppDirectories.appDataDir
val logsDir = AppDirectories.logsDir
assertTrue(logsDir.absolutePath.startsWith(appDataDir.absolutePath), "Logs dir should be inside app data dir")
assertTrue(logsDir.name == "logs", "Logs directory should be named 'logs'")
}
}

View file

@ -0,0 +1,67 @@
package com.rukira.wowbackup.ui.status
import com.rukira.wowbackup.update.UpdateInfo
import com.rukira.wowbackup.update.UpdateService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@OptIn(ExperimentalCoroutinesApi::class)
class StatusViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testViewModelReceivesUpdateInfo() = runTest(testDispatcher) {
val mockService = object : UpdateService {
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo {
return UpdateInfo(
latestVersion = "1.0.2",
releaseUrl = "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2",
)
}
}
val viewModel = StatusViewModel(updateService = mockService)
advanceUntilIdle()
val state = viewModel.state.first { it.updateInfo != null }
assertNotNull(state.updateInfo)
assertEquals("1.0.2", state.updateInfo!!.latestVersion)
assertEquals("https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", state.updateInfo!!.releaseUrl)
}
@Test
fun testViewModelHandlesNoUpdate() = runTest(testDispatcher) {
val mockService = object : UpdateService {
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo? = null
}
val viewModel = StatusViewModel(updateService = mockService)
advanceUntilIdle()
val state = viewModel.state.value
assertNull(state.updateInfo)
}
}

View file

@ -0,0 +1,97 @@
package com.rukira.wowbackup.update
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SemVerTest {
@Test
fun testParseStandardVersions() {
val v1 = SemVer.parseOrNull("1.0.0")
assertNotNull(v1)
assertEquals(1, v1.major)
assertEquals(0, v1.minor)
assertEquals(0, v1.patch)
assertNull(v1.preRelease)
val v2 = SemVer.parseOrNull("v1.2.3")
assertNotNull(v2)
assertEquals(1, v2.major)
assertEquals(2, v2.minor)
assertEquals(3, v2.patch)
assertNull(v2.preRelease)
val v3 = SemVer.parseOrNull("V2.0")
assertNotNull(v3)
assertEquals(2, v3.major)
assertEquals(0, v3.minor)
assertEquals(0, v3.patch)
val v4 = SemVer.parseOrNull("0.0.1")
assertNotNull(v4)
assertEquals(0, v4.major)
assertEquals(0, v4.minor)
assertEquals(1, v4.patch)
}
@Test
fun testParsePreReleaseVersions() {
val v = SemVer.parseOrNull("1.0.0-beta.1")
assertNotNull(v)
assertEquals(1, v.major)
assertEquals(0, v.minor)
assertEquals(0, v.patch)
assertEquals("beta.1", v.preRelease)
}
@Test
fun testParseInvalidVersions() {
assertNull(SemVer.parseOrNull(null))
assertNull(SemVer.parseOrNull(""))
assertNull(SemVer.parseOrNull(" "))
assertNull(SemVer.parseOrNull("invalid"))
assertNull(SemVer.parseOrNull("1.a.3"))
assertNull(SemVer.parseOrNull("1.2.3.4"))
}
@Test
fun testVersionComparison() {
val v001 = SemVer.parseOrNull("0.0.1")!!
val v100 = SemVer.parseOrNull("1.0.0")!!
val v101 = SemVer.parseOrNull("1.0.1")!!
val v102 = SemVer.parseOrNull("v1.0.2")!!
val v110 = SemVer.parseOrNull("1.1.0")!!
val v200 = SemVer.parseOrNull("2.0.0")!!
assertTrue(v100.isNewerThan(v001))
assertTrue(v101.isNewerThan(v100))
assertTrue(v102.isNewerThan(v101))
assertTrue(v110.isNewerThan(v102))
assertTrue(v200.isNewerThan(v110))
assertFalse(v101.isNewerThan(v102))
assertFalse(v102.isNewerThan(SemVer.parseOrNull("1.0.2")!!))
}
@Test
fun testPreReleaseComparison() {
val release = SemVer.parseOrNull("1.0.0")!!
val preRelease = SemVer.parseOrNull("1.0.0-alpha")!!
val preReleaseBeta = SemVer.parseOrNull("1.0.0-beta")!!
assertTrue(release.isNewerThan(preRelease))
assertFalse(preRelease.isNewerThan(release))
assertTrue(preReleaseBeta.isNewerThan(preRelease))
}
@Test
fun testToString() {
assertEquals("1.0.0", SemVer.parseOrNull("1.0.0")?.toString())
assertEquals("1.2.0", SemVer.parseOrNull("v1.2")?.toString())
assertEquals("1.0.0-rc1", SemVer.parseOrNull("1.0.0-rc1")?.toString())
}
}

View file

@ -0,0 +1,172 @@
package com.rukira.wowbackup.update
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import java.net.InetSocketAddress
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class UpdateCheckerTest {
private lateinit var server: HttpServer
private var serverPort: Int = 0
@BeforeTest
fun setUp() {
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
serverPort = server.address.port
server.start()
}
@AfterTest
fun tearDown() {
server.stop(0)
}
@Test
fun testUpdateAvailableWhenRemoteIsNewer() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2",
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
fallbackUrl = "https://git.asarius.site/rukira/wow-backup/releases",
)
val result = checker.checkForUpdate(currentVersion = "1.0.1")
assertNotNull(result)
assertEquals("1.0.2", result.latestVersion)
assertEquals("https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", result.releaseUrl)
}
@Test
fun testNoUpdateWhenUpToDate() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2",
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "1.0.2")
assertNull(result)
}
@Test
fun testNoUpdateWhenRunningNewerVersion() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2",
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "2.0.0")
assertNull(result)
}
@Test
fun testHttpErrorGracefullyReturnsNull() = runBlocking {
server.createContext("/releases/latest") { exchange ->
exchange.sendResponseHeaders(404, 0)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "1.0.0")
assertNull(result)
}
@Test
fun testMalformedJsonGracefullyReturnsNull() = runBlocking {
server.createContext("/releases/latest") { exchange ->
val bytes = "Not valid JSON".toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "1.0.0")
assertNull(result)
}
@Test
fun testFallbackUrlUsedWhenHtmlUrlMissing() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
fallbackUrl = "https://git.asarius.site/rukira/wow-backup/releases",
)
val result = checker.checkForUpdate(currentVersion = "1.0.0")
assertNotNull(result)
assertEquals("https://git.asarius.site/rukira/wow-backup/releases", result.releaseUrl)
}
}

View file

@ -14,6 +14,7 @@ materialKolor = "2.0.0"
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-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } 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" } cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }