diff --git a/INSTALL.md b/INSTALL.md index 6d6198f..c232741 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,18 +1,22 @@ # Installing WoW Backup -## Download - -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 | -|--------------------------|---|---| -| **macOS** | DMG Disk Image | `WoW Backup-.dmg` | -| **Windows** | Windows Installer (MSI) | `WoW Backup-.msi` | -| **Linux** (Experimental) | Debian Package (DEB) | `wow-backup__amd64.deb` | +This guide provides step-by-step instructions for installing and running **WoW Backup** on macOS, Windows, and Linux. --- -## macOS +## Download + +Download the appropriate installer for your platform from the **[GitHub Releases](https://github.com/rukira/wow-backup/releases)** page: + +| Platform | Package Format | Installer File Pattern | +|---|---|---| +| **macOS** | DMG Disk Image | `WoW Backup-.dmg` | +| **Windows** | Windows Installer (MSI) | `WoW Backup-.msi` | +| **Linux** | Debian Package (DEB) | `wow-backup__amd64.deb` | + +--- + +## macOS Installation 1. Download the `.dmg` file from the latest release. 2. Double-click the `.dmg` file to mount the disk image. @@ -23,14 +27,20 @@ Download the appropriate installer for your platform from the **[latest release] 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: -1. Open **System Settings** -2. Navigate to **Security & Privacy**. -3. Click **Open Anyway** under **Allow apps downloaded from**. -4. Restart WoW Backup. + +- **Option A (Finder)**: + 1. Open Finder and navigate to `/Applications`. + 2. Right-click (or Control-click) on **WoW Backup.app** and choose **Open**. + 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 +## Windows Installation 1. Download the `.msi` installer from the latest release. 2. Double-click the `.msi` file to start the installation wizard. @@ -44,7 +54,7 @@ If Windows Defender SmartScreen flags the installer (*"Windows protected your PC --- -## Linux (Experimental) +## Linux Installation 1. Download the `.deb` package from the latest release. 2. Open your terminal in the download folder and install the package: @@ -58,3 +68,17 @@ 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. +--- + +## 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. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index fdddb29..0000000 --- a/LICENSE +++ /dev/null @@ -1,24 +0,0 @@ -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 diff --git a/README.md b/README.md index b533624..3db5374 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,22 @@ # WoW Backup -A lightweight, native-feeling desktop application to automatically backup World of Warcraft configuration data (WTF and Interface directories). +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. -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). +## Overview -## 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. +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. ## 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. -- ⏰ **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 limit. -- 🎮 **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. +- ⏰ **Automated & Scheduled Backups**: Background scheduler powered by Cardiologist ensures dependable daily 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 (1–30 backups). +- 🎮 **Game State Detection**: Checks for active WoW processes before running backups to prevent file-locking conflicts, with optional "force backup" override and safety confirmation. +- 🔔 **Native OS Notifications**: Notifies the user when backups start, succeed, fail, or are skipped due to game execution. - 🚀 **Launch at Startup**: Integrated with macOS LaunchAgents (`~/Library/LaunchAgents/`) and Windows Registry (`HKCU\...\Run`). -- 🎨 **Theming & Customization**: Dynamic theming supporting Light/Dark mode, with configurable accent colors. - ---- - -## 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`) +- 🎨 **Modern Theming & Customization**: Dynamic Material 3 theming supporting System/Light/Dark modes and configurable accent colors (via MaterialKolor). +- 🔄 **Restore System (In Progress)**: Interactive restore interface to safely roll back WTF and Interface configurations. --- @@ -34,14 +24,22 @@ The app uses a configuration file to store user preferences. This file and runti - **Language & Runtime**: Kotlin Multiplatform targeting Desktop (JVM, Java 17+) - **UI Framework**: JetBrains Compose Multiplatform with Material 3 Design -- **Architecture**: MVVM with Kotlin Coroutines -- **Scheduling**: `io.github.kevincianfarini.cardiologist` -- **Logging**: `logback-classic` + `kotlin-logging-jvm` with daily rotation and archiving -- **Serialization**: Settings stored in a JSON file using `kotlinx-serialization-json` +- **Architecture**: MVI / MVVM with Kotlin Coroutines and StateFlow +- **Scheduling**: `io.github.kevincianfarini.cardiologist` for drift-free daily execution +- **Logging**: `logback-classic` + `kotlin-logging-jvm` with daily rotation and size archiving in app data directories +- **Serialization**: `kotlinx-serialization-json` for typed JSON configuration persistence - **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 ### Prerequisites @@ -73,6 +71,22 @@ The app uses a configuration file to store user preferences. This file and runti --- -## License +## Feature Implementation Status -This project is released into the public domain. See the [LICENSE](LICENSE) file or [unlicense.org](https://unlicense.org/) for details. +| Feature | Plan Document | Status | Description | +|---|---|---|---| +| **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`) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9e78e71..f0bb771 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -31,21 +31,16 @@ kotlin { implementation(libs.logback.classic) implementation(libs.material.kolor) } - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutinesTest) - } } } -val appVersion = providers.gradleProperty("appVersion").orNull?.takeIf { it.isNotBlank() } ?: "0.0.1" +val appVersion = (project.findProperty("appVersion") as? String)?.takeIf { it.isNotBlank() } ?: "1.0.0" val generatedSrcDir = layout.buildDirectory.dir("generated/src/jvmMain/kotlin") val generateBuildConfig by tasks.registering { val outputDir = generatedSrcDir val version = appVersion - inputs.property("version", version) outputs.dir(outputDir) doLast { val dir = outputDir.get().asFile.resolve("com/rukira/wowbackup") @@ -77,8 +72,6 @@ compose.desktop { packageName = "WoW Backup" packageVersion = appVersion - modules("java.naming", "java.sql", "java.instrument", "jdk.unsupported") - macOS { bundleID = "com.rukira.wowbackup" iconFile.set(project.file("src/jvmMain/resources/icon.icns")) diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/logging/LoggingSetup.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/logging/LoggingSetup.kt index 03f3e70..e4fc392 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/logging/LoggingSetup.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/logging/LoggingSetup.kt @@ -12,12 +12,7 @@ import java.io.File object LoggingSetup { - private var initialized = false - fun init() { - if (initialized) return - initialized = true - val logDir = AppDirectories.logsDir val logFile = File(logDir, "wowbackup.log") @@ -31,7 +26,7 @@ object LoggingSetup { val rollingPolicy = SizeAndTimeBasedRollingPolicy().apply { this.context = context - fileNamePattern = "${logDir.absolutePath}/wowbackup.%d{yyyy-MM-dd}.%i.log.gz" + fileNamePattern = "${logDir}/wowbackup.%d{yyyy-MM-dd}.%i.log.gz" setMaxFileSize(FileSize.valueOf("10MB")) maxHistory = 30 setTotalSizeCap(FileSize.valueOf("100MB")) @@ -51,10 +46,5 @@ object LoggingSetup { val rootLogger = context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME) rootLogger.addAppender(fileAppender) - - Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - val uncaughtLogger = LoggerFactory.getLogger("UncaughtExceptionHandler") - uncaughtLogger.error("Uncaught exception on thread [${thread.name}]", throwable) - } } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt index 5593611..5c41dcb 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt @@ -3,7 +3,6 @@ package com.rukira.wowbackup.platform import io.github.oshai.kotlinlogging.KotlinLogging import java.awt.Desktop import java.io.File -import java.net.URI private val logger = KotlinLogging.logger {} @@ -16,16 +15,4 @@ object DesktopActions { 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" } - } - } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt index 02ffa13..8809df5 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt @@ -2,7 +2,6 @@ package com.rukira.wowbackup.ui.status import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -215,33 +214,12 @@ fun StatusScreen( } Spacer(Modifier.weight(1f)) - Row( + Text( + "WoW Backup v${BuildConfig.VERSION}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth(), - 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) - } - } - } + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt index 3227a0e..ff36ca0 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt @@ -7,18 +7,13 @@ import com.rukira.wowbackup.backup.BackupProgress import com.rukira.wowbackup.backup.BackupResult import com.rukira.wowbackup.backup.BackupScheduler 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.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -37,25 +32,10 @@ data class StatusUiState( val backupProgress: BackupProgress? = null, val totalBackups: Int = 0, val backupPath: String? = null, - val updateInfo: UpdateInfo? = null, ) @OptIn(kotlin.time.ExperimentalTime::class) -class StatusViewModel( - private val updateService: UpdateService = UpdateChecker(), -) : ViewModel() { - - private val updateInfo = MutableStateFlow(null) - - init { - viewModelScope.launch(Dispatchers.IO) { - try { - updateInfo.value = updateService.checkForUpdate() - } catch (e: Exception) { - // Silently handle any uncaught exception - } - } - } +class StatusViewModel : ViewModel() { // Only recomputes when backupPath or lastBackupResult changes — not on every progress tick private val totalBackups = combine( @@ -73,8 +53,7 @@ class StatusViewModel( ConfigManager.config, BackupScheduler.state, totalBackups, - updateInfo, - ) { config, scheduler, backupCount, update -> + ) { config, scheduler, backupCount -> StatusUiState( isConfigured = config.isConfigured, lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) }, @@ -87,7 +66,6 @@ class StatusViewModel( backupProgress = scheduler.currentProgress, totalBackups = backupCount, backupPath = config.backupPath, - updateInfo = update, ) }.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState()) diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/SemVer.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/SemVer.kt deleted file mode 100644 index 27efe6f..0000000 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/SemVer.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.rukira.wowbackup.update - -data class SemVer( - val major: Int, - val minor: Int = 0, - val patch: Int = 0, - val preRelease: String? = null, -) : Comparable { - - 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) - } - } -} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/UpdateChecker.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/UpdateChecker.kt deleted file mode 100644 index 5b8e315..0000000 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/UpdateChecker.kt +++ /dev/null @@ -1,102 +0,0 @@ -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(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" - } -} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/BuildConfigTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/BuildConfigTest.kt deleted file mode 100644 index 91996fe..0000000 --- a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/BuildConfigTest.kt +++ /dev/null @@ -1,11 +0,0 @@ -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") - } -} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/logging/LoggingSetupTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/logging/LoggingSetupTest.kt deleted file mode 100644 index ab5c074..0000000 --- a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/logging/LoggingSetupTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -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'") - } -} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/ui/status/StatusViewModelTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/ui/status/StatusViewModelTest.kt deleted file mode 100644 index fe72ba2..0000000 --- a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/ui/status/StatusViewModelTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -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) - } -} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/SemVerTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/SemVerTest.kt deleted file mode 100644 index 7032f9d..0000000 --- a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/SemVerTest.kt +++ /dev/null @@ -1,97 +0,0 @@ -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()) - } -} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/UpdateCheckerTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/UpdateCheckerTest.kt deleted file mode 100644 index c728994..0000000 --- a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/UpdateCheckerTest.kt +++ /dev/null @@ -1,172 +0,0 @@ -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) - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8a2a2a..9e629fd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,6 @@ materialKolor = "2.0.0" 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" } 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-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }