diff --git a/.gitea/issue_template/bug_report.yaml b/.gitea/issue_template/bug_report.yaml index f8e95c4..bc436de 100644 --- a/.gitea/issue_template/bug_report.yaml +++ b/.gitea/issue_template/bug_report.yaml @@ -62,6 +62,7 @@ body: options: - macOS - Windows + - Linux validations: required: true @@ -80,6 +81,7 @@ body: Logs help us understand what went wrong behind the scenes. Here's how to find them: **macOS:** Open Finder, press **Cmd+Shift+G**, and paste: `~/Library/Application Support/WoWBackup/logs` + **Windows:** Press **Win+R**, and paste: `%APPDATA%\WoWBackup\logs` You can also open this folder from the app: go to **Settings** and click the **Open Logs** button at the bottom. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5076c0e..b2360d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,7 +154,7 @@ jobs: #### Available Packages - **macOS**: \`.dmg\` installer (Apple Silicon & Intel) - **Windows**: \`.msi\` Windows Installer - - **Linux**: \`.deb\` package (Debian / Ubuntu) + - **Linux (Experimental)**: \`.deb\` package (Debian / Ubuntu) --- For detailed setup and installation notes, please refer to [INSTALL.md](https://git.asarius.site/rukira/wow-backup/src/tag/${RELEASE_TAG}/INSTALL.md)." @@ -206,3 +206,33 @@ jobs: done echo "Forgejo release published successfully." + + - name: Update Homebrew Tap Cask + env: + FORGEJO_PAT: ${{ secrets.FORGEJO_PAT }} + RELEASE_VERSION: ${{ needs.bump-version.outputs.version }} + run: | + DMG_FILE=$(find dist -name "*.dmg" | head -n 1) + if [ -z "$DMG_FILE" ] || [ ! -f "$DMG_FILE" ]; then + echo "::error::No DMG file found in dist/ to update Homebrew Cask." + exit 1 + fi + + SHA256=$(sha256sum "$DMG_FILE" | awk '{print $1}') + echo "Calculated DMG SHA256: ${SHA256} for version ${RELEASE_VERSION}" + + echo "Cloning homebrew-tap repository..." + git clone "https://rukira:${FORGEJO_PAT}@git.asarius.site/rukira/homebrew-tap.git" tap-repo + cd tap-repo + + echo "Updating Casks/wow-backup.rb..." + sed -i "s/version \".*\"/version \"${RELEASE_VERSION}\"/" Casks/wow-backup.rb + sed -i "s/sha256 \".*\"/sha256 \"${SHA256}\"/" Casks/wow-backup.rb + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Casks/wow-backup.rb + git commit -m "chore(cask): update wow-backup to v${RELEASE_VERSION}" || echo "No changes to commit" + git push origin main + + echo "Homebrew tap updated successfully." diff --git a/INSTALL.md b/INSTALL.md index c232741..1e39ff3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,46 +1,20 @@ # Installing WoW Backup -This guide provides step-by-step instructions for installing and running **WoW Backup** on macOS, Windows, and Linux. - ---- - ## 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/latest)**: -| 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` | +| 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` | + +_Note: The Linux version is untested and experimental. Use at your own risk._ --- -## macOS Installation - -1. Download the `.dmg` file from the latest release. -2. Double-click the `.dmg` file to mount the disk image. -3. Drag **WoW Backup.app** into your `/Applications` folder. -4. Eject the disk image. - -### Unsigned Application / Gatekeeper Notice -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: - -- **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 Installation +## Windows 1. Download the `.msi` installer from the latest release. 2. Double-click the `.msi` file to start the installation wizard. @@ -54,7 +28,59 @@ If Windows Defender SmartScreen flags the installer (*"Windows protected your PC --- -## Linux Installation +## macOS + +### Option 1: Manual DMG Download + +1. Download the `.dmg` file from the **[latest release](https://git.asarius.site/rukira/wow-backup/releases/latest)**. +2. Double-click the `.dmg` file to mount the disk image. +3. Drag **WoW Backup.app** into your `/Applications` folder. +4. Eject the disk image. + +#### Unsigned Application / Gatekeeper Notice +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: + +- **Terminal (Fastest):** Run the following command to strip the quarantine attribute: + ```shell + xattr -cr "/Applications/WoW Backup.app" + ``` +- **macOS System Settings:** + 1. Open **System Settings** → **Privacy & Security**. + 2. Scroll down to the **Security** section. + 3. Click **Open Anyway** next to the WoW Backup notice. + 4. Confirm and launch the app. + +--- + +### Option 2: Install via [Homebrew](https://brew.sh/) + +**Install:** +1. Add the tap: + ```shell + brew tap rukira/tap https://git.asarius.site/rukira/homebrew-tap.git + ``` + +2. Install WoW Backup: + ```shell + brew install --cask wow-backup + ``` + +**Update:** + ```shell + brew update + brew upgrade --cask wow-backup + ``` + +**Uninstall:** + ```shell + brew uninstall --cask --zap wow-backup + ``` + +--- + +## Linux (Experimental) 1. Download the `.deb` package from the latest release. 2. Open your terminal in the download folder and install the package: @@ -68,17 +94,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. ---- - -## 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/README.md b/README.md index 3db5374..5fd72ff 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,28 @@ -# WoW Backup +![WoW Backup](docs/screenshots/hero.png) -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. +## Installation & Download -## Overview +Pre-built binaries for **macOS**, **Windows**, and **Linux** are provided on each release. -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. +👉 **See [here](INSTALL.md)** for download links and step-by-step installation instructions. ## 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 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. +- ⏰ **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. - 🚀 **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). -- 🔄 **Restore System (In Progress)**: Interactive restore interface to safely roll back WTF and Interface configurations. +- 🎨 **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`) --- @@ -24,22 +30,14 @@ World of Warcraft stores all UI configurations, macros, keybindings, and addon s - **Language & Runtime**: Kotlin Multiplatform targeting Desktop (JVM, Java 17+) - **UI Framework**: JetBrains Compose Multiplatform with Material 3 Design -- **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 +- **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` - **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 @@ -71,22 +69,6 @@ Pre-built binaries for macOS, Windows, and Linux are automatically packaged and --- -## Feature Implementation Status +## License -| 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`) +This project is released into the public domain. See the [UNLICENSE](UNLICENSE) file or [unlicense.org](https://unlicense.org/) for details. diff --git a/UNLICENSE b/UNLICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/UNLICENSE @@ -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 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index f0bb771..c7891ab 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -12,12 +12,12 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - implementation(compose.ui) - implementation(compose.components.resources) - implementation(compose.components.uiToolingPreview) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.ui) + implementation(libs.compose.components.resources) + implementation(libs.compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) } @@ -31,16 +31,22 @@ kotlin { implementation(libs.logback.classic) 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 generateBuildConfig by tasks.registering { +val generateBuildConfig = tasks.register("generateBuildConfig") { + description = "Generates BuildConfig.kt" val outputDir = generatedSrcDir val version = appVersion + inputs.property("version", version) outputs.dir(outputDir) doLast { val dir = outputDir.get().asFile.resolve("com/rukira/wowbackup") @@ -65,16 +71,24 @@ compose.desktop { application { mainClass = "com.rukira.wowbackup.MainKt" - jvmArgs("-Dapple.awt.enableTemplateImages=true") + jvmArgs("-Dapple.awt.enableTemplateImages=true", "-Dapple.awt.UIElement=true") nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "WoW Backup" packageVersion = appVersion + modules("java.naming", "java.sql", "java.instrument", "jdk.unsupported", "java.net.http") + macOS { bundleID = "com.rukira.wowbackup" iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + infoPlist { + extraKeysRawXml = """ + LSUIElement + + """.trimIndent() + } } windows { 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 e4fc392..03f3e70 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/logging/LoggingSetup.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/logging/LoggingSetup.kt @@ -12,7 +12,12 @@ 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") @@ -26,7 +31,7 @@ object LoggingSetup { val rollingPolicy = SizeAndTimeBasedRollingPolicy().apply { 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")) maxHistory = 30 setTotalSizeCap(FileSize.valueOf("100MB")) @@ -46,5 +51,10 @@ 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/main.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt index a9e0d8b..aee168b 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt @@ -28,6 +28,9 @@ import java.awt.GraphicsEnvironment private val logger = KotlinLogging.logger {} fun main() { + if (Platform.current == OS.Mac) { + System.setProperty("apple.awt.UIElement", "true") + } LoggingSetup.init() logger.info { "WoW Backup starting" } 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 5c41dcb..5593611 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt @@ -3,6 +3,7 @@ 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 {} @@ -15,4 +16,16 @@ 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/config/ConfigScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt index 63922c0..95dd10d 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt @@ -63,214 +63,234 @@ fun ConfigScreen( var showForceBackupDialog by remember { mutableStateOf(false) } val scrollState = rememberScrollState() - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text("Settings", style = MaterialTheme.typography.headlineMedium) + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Settings", style = MaterialTheme.typography.headlineMedium) - // === WoW Installation === - SectionHeader("WoW Installation") + // === WoW Installation === + SectionHeader("WoW Installation") - PathField( - label = "WoW install location", - value = config.wowInstallPath ?: "", - error = errors["wowPath"], - onBrowse = { - val dir = pickFolder("Select WoW Install Folder", config.wowInstallPath?.let { File(it) }) - if (dir != null) viewModel.updateWoWPath(dir.absolutePath) - }, - ) + PathField( + label = "WoW install location", + value = config.wowInstallPath ?: "", + error = errors["wowPath"], + onBrowse = { + val dir = pickFolder("Select WoW Install Folder", config.wowInstallPath?.let { File(it) }) + if (dir != null) viewModel.updateWoWPath(dir.absolutePath) + }, + ) - // WoW path warnings (e.g. missing WTF/Interface, permission issues) - uiState.warnings["wowPath"]?.forEach { warning -> - Text( - warning, - color = MaterialTheme.colorScheme.tertiary, - style = MaterialTheme.typography.bodySmall, - ) - } - - // WoW validation summary when valid - uiState.wowValidation?.let { validation -> - if (validation.isValid) { - val wtfStatus = when { - validation.wtfReadable -> "WTF folder: found" - validation.hasWtf -> "WTF folder: found (not readable)" - else -> "WTF folder: not found" + // WoW path warnings (e.g. missing WTF/Interface, permission issues) + uiState.warnings["wowPath"]?.forEach { warning -> + Text( + warning, + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodySmall, + ) } - val interfaceStatus = when { - validation.interfaceReadable -> "Interface folder: found" - validation.hasInterface -> "Interface folder: found (not readable)" - else -> "Interface folder: not found" + + // WoW validation summary when valid + uiState.wowValidation?.let { validation -> + if (validation.isValid) { + val wtfStatus = when { + validation.wtfReadable -> "WTF folder: found" + validation.hasWtf -> "WTF folder: found (not readable)" + else -> "WTF folder: not found" + } + val interfaceStatus = when { + validation.interfaceReadable -> "Interface folder: found" + validation.hasInterface -> "Interface folder: found (not readable)" + else -> "Interface folder: not found" + } + Text( + "$wtfStatus | $interfaceStatus", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } + + OutlinedButton(onClick = { viewModel.detectWoWLocation() }) { + Text("Auto-detect") + } + + // === Backup Settings === + SectionHeader("Backup Settings") + + PathField( + label = "Backup destination", + value = config.backupPath ?: "", + error = errors["backupPath"], + onBrowse = { + val dir = pickFolder("Select Backup Destination", config.backupPath?.let { File(it) }) + if (dir != null) viewModel.updateBackupPath(dir.absolutePath) + }, + ) + + Text("Daily backup time", style = MaterialTheme.typography.bodyMedium) + TimePicker( + value = config.backupTimeOfDay, + onValueChange = { viewModel.updateBackupTime(it) }, + modifier = Modifier.width(240.dp), + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Backups to keep:", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.width(8.dp)) + HistoryCountSelector( + value = config.backupHistoryCount, + onValueChange = { viewModel.updateBackupHistoryCount(it) }, + ) + } + + CheckboxRow( + checked = config.forceBackupWhileRunning, + onCheckedChange = { checked -> + if (checked) { + showForceBackupDialog = true + } else { + viewModel.updateForceBackup(false) + } + }, + label = "Allow backup while WoW is running", + ) + + // === Folders to Backup === + SectionHeader("Folders to Backup") + + if (errors.containsKey("folders")) { + Text(errors["folders"]!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + CheckboxRow( + checked = config.backupWtf, + onCheckedChange = { viewModel.updateBackupWtf(it) }, + label = "WTF", + description = "Contains account settings, keybinds, macros, and addon saved variables.", + ) + + CheckboxRow( + checked = config.backupInterface, + onCheckedChange = { viewModel.updateBackupInterface(it) }, + label = "Interface", + description = "Contains installed addons.", + ) + + // === Options === + SectionHeader("Options") + + CheckboxRow( + checked = config.compressionEnabled, + onCheckedChange = { viewModel.updateCompression(it) }, + label = "Compression", + description = "Compress backups to save disk space. Slightly slower backup/restore.", + ) + + CheckboxRow( + checked = config.notificationsEnabled, + onCheckedChange = { viewModel.updateNotifications(it) }, + label = "Notifications", + description = "Show system notifications for backup events.", + ) + + CheckboxRow( + checked = config.runAtStartup, + onCheckedChange = { viewModel.updateRunAtStartup(it) }, + label = "Run at startup", + description = "Launch WoW Backup automatically when you log in.", + ) + + // === Appearance === + SectionHeader("Appearance") + + Text("Theme", style = MaterialTheme.typography.bodyMedium) + SingleChoiceSegmentedButtonRow { + ThemeMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + selected = config.themeMode == mode, + onClick = { viewModel.updateThemeMode(mode) }, + shape = SegmentedButtonDefaults.itemShape(index, ThemeMode.entries.size), + ) { + Text( + when (mode) { + ThemeMode.SYSTEM -> "System" + ThemeMode.LIGHT -> "Light" + ThemeMode.DARK -> "Dark" + }, + ) + } + } + } + + Text("Accent color", style = MaterialTheme.typography.bodyMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AccentColor.entries.forEach { color -> + val isSelected = config.accentColor == color + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(color.seedColor, CircleShape) + .then( + if (isSelected) { + Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + } else { + Modifier + }, + ) + .clickable { viewModel.updateAccentColor(color) }, + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Text( + "\u2713", + color = MaterialTheme.colorScheme.surface, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + Text( - "$wtfStatus | $interfaceStatus", + "Theme changes apply immediately.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } - OutlinedButton(onClick = { viewModel.detectWoWLocation() }) { - Text("Auto-detect") - } - - // === Backup Settings === - SectionHeader("Backup Settings") - - PathField( - label = "Backup destination", - value = config.backupPath ?: "", - error = errors["backupPath"], - onBrowse = { - val dir = pickFolder("Select Backup Destination", config.backupPath?.let { File(it) }) - if (dir != null) viewModel.updateBackupPath(dir.absolutePath) - }, - ) - - Text("Daily backup time", style = MaterialTheme.typography.bodyMedium) - TimePicker( - value = config.backupTimeOfDay, - onValueChange = { viewModel.updateBackupTime(it) }, - modifier = Modifier.width(240.dp), - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Backups to keep:", style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.width(8.dp)) - HistoryCountSelector( - value = config.backupHistoryCount, - onValueChange = { viewModel.updateBackupHistoryCount(it) }, + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + adapter = rememberScrollbarAdapter(scrollState), + style = ScrollbarStyle( + minimalHeight = 48.dp, + thickness = 8.dp, + shape = RoundedCornerShape(4.dp), + hoverDurationMillis = 300, + unhoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + hoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ), ) } - CheckboxRow( - checked = config.forceBackupWhileRunning, - onCheckedChange = { checked -> - if (checked) { - showForceBackupDialog = true - } else { - viewModel.updateForceBackup(false) - } - }, - label = "Allow backup while WoW is running", - ) - - // === Folders to Backup === - SectionHeader("Folders to Backup") - - if (errors.containsKey("folders")) { - Text(errors["folders"]!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) - } - - CheckboxRow( - checked = config.backupWtf, - onCheckedChange = { viewModel.updateBackupWtf(it) }, - label = "WTF", - description = "Contains account settings, keybinds, macros, and addon saved variables.", - ) - - CheckboxRow( - checked = config.backupInterface, - onCheckedChange = { viewModel.updateBackupInterface(it) }, - label = "Interface", - description = "Contains installed addons.", - ) - - // === Options === - SectionHeader("Options") - - CheckboxRow( - checked = config.compressionEnabled, - onCheckedChange = { viewModel.updateCompression(it) }, - label = "Compression", - description = "Compress backups to save disk space. Slightly slower backup/restore.", - ) - - CheckboxRow( - checked = config.notificationsEnabled, - onCheckedChange = { viewModel.updateNotifications(it) }, - label = "Notifications", - description = "Show system notifications for backup events.", - ) - - CheckboxRow( - checked = config.runAtStartup, - onCheckedChange = { viewModel.updateRunAtStartup(it) }, - label = "Run at startup", - description = "Launch WoW Backup automatically when you log in.", - ) - - // === Appearance === - SectionHeader("Appearance") - - Text("Theme", style = MaterialTheme.typography.bodyMedium) - SingleChoiceSegmentedButtonRow { - ThemeMode.entries.forEachIndexed { index, mode -> - SegmentedButton( - selected = config.themeMode == mode, - onClick = { viewModel.updateThemeMode(mode) }, - shape = SegmentedButtonDefaults.itemShape(index, ThemeMode.entries.size), - ) { - Text( - when (mode) { - ThemeMode.SYSTEM -> "System" - ThemeMode.LIGHT -> "Light" - ThemeMode.DARK -> "Dark" - }, - ) - } - } - } - - Text("Accent color", style = MaterialTheme.typography.bodyMedium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - AccentColor.entries.forEach { color -> - val isSelected = config.accentColor == color - Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(color.seedColor, CircleShape) - .then( - if (isSelected) { - Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) - } else { - Modifier - }, - ) - .clickable { viewModel.updateAccentColor(color) }, - contentAlignment = Alignment.Center, - ) { - if (isSelected) { - Text( - "\u2713", - color = MaterialTheme.colorScheme.surface, - style = MaterialTheme.typography.labelLarge, - ) - } - } - } - } - - Text( - "Theme changes apply immediately.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - // === Footer === - Spacer(Modifier.height(8.dp)) + // === Sticky Footer === HorizontalDivider() - Spacer(Modifier.height(8.dp)) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { @@ -303,20 +323,6 @@ fun ConfigScreen( } } - VerticalScrollbar( - modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), - adapter = rememberScrollbarAdapter(scrollState), - style = ScrollbarStyle( - minimalHeight = 48.dp, - thickness = 8.dp, - shape = RoundedCornerShape(4.dp), - hoverDurationMillis = 300, - unhoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), - hoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ), - ) - } - // Force backup confirmation dialog if (showForceBackupDialog) { ConfirmationDialog( 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 8809df5..02ffa13 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,6 +2,7 @@ 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 @@ -214,12 +215,33 @@ fun StatusScreen( } Spacer(Modifier.weight(1f)) - Text( - "WoW Backup v${BuildConfig.VERSION}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Row( 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) + } + } + } } } 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 ff36ca0..3227a0e 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,13 +7,18 @@ 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 @@ -32,10 +37,25 @@ 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 : ViewModel() { +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 + } + } + } // Only recomputes when backupPath or lastBackupResult changes — not on every progress tick private val totalBackups = combine( @@ -53,7 +73,8 @@ class StatusViewModel : ViewModel() { ConfigManager.config, BackupScheduler.state, totalBackups, - ) { config, scheduler, backupCount -> + updateInfo, + ) { config, scheduler, backupCount, update -> StatusUiState( isConfigured = config.isConfigured, lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) }, @@ -66,6 +87,7 @@ class StatusViewModel : ViewModel() { 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/ui/theme/Theme.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt index 9b4c7e7..0f5fd4d 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt @@ -20,7 +20,7 @@ fun WoWBackupTheme( DynamicMaterialTheme( seedColor = accentColor.seedColor, - useDarkTheme = useDarkTheme, + isDark = useDarkTheme, content = content, ) } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/SemVer.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/SemVer.kt new file mode 100644 index 0000000..27efe6f --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/SemVer.kt @@ -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 { + + 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 new file mode 100644 index 0000000..5b8e315 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/UpdateChecker.kt @@ -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(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 new file mode 100644 index 0000000..91996fe --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/BuildConfigTest.kt @@ -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") + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/logging/LoggingSetupTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/logging/LoggingSetupTest.kt new file mode 100644 index 0000000..ab5c074 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/logging/LoggingSetupTest.kt @@ -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'") + } +} 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 new file mode 100644 index 0000000..fe72ba2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/ui/status/StatusViewModelTest.kt @@ -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) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/SemVerTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/SemVerTest.kt new file mode 100644 index 0000000..7032f9d --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/SemVerTest.kt @@ -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()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/UpdateCheckerTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/UpdateCheckerTest.kt new file mode 100644 index 0000000..c728994 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/UpdateCheckerTest.kt @@ -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) + } +} diff --git a/docs/plans/feature-1-system-tray.md b/docs/plans/feature-1-system-tray.md index 699456f..826321a 100644 --- a/docs/plans/feature-1-system-tray.md +++ b/docs/plans/feature-1-system-tray.md @@ -84,18 +84,31 @@ Strip all template code. Route to placeholder screens based on `currentScreen`: ### Step 5: Auto-show config if not configured In `main.kt`, on startup check `ConfigManager.isConfigured`. If false, set `currentScreen = Screen.CONFIG` and `isWindowVisible = true`. -### Step 6: macOS JVM args +### Step 6: macOS JVM args and Dock hiding (LSUIElement) **Files to modify:** - `composeApp/build.gradle.kts` — add to desktop application config: ```kotlin compose.desktop { application { - jvmArgs("-Dapple.awt.enableTemplateImages=true") + jvmArgs("-Dapple.awt.enableTemplateImages=true", "-Dapple.awt.UIElement=true") + + nativeDistributions { + macOS { + infoPlist { + extraKeysRawXml = """ + LSUIElement + + """.trimIndent() + } + } + } } } ``` +Also set `System.setProperty("apple.awt.UIElement", "true")` in `main.kt` for macOS so that the dock icon is suppressed during development runs. + ## Verification 1. `./gradlew composeApp:run` — app launches with tray icon visible 2. Tray icon shows context menu with Status, Settings, Quit diff --git a/docs/roadmap.md b/docs/roadmap.md index bcbbaec..5afa40c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,75 +4,13 @@ This document outlines the current state of the project, remaining core features --- -## 1. Immediate Priority: Feature 5 (Restore System) +## Immediate Priority: Feature 5 (Restore System) The core backup and scheduling pipeline is complete, but the restoration flow is currently a stub placeholder. Implementing Feature 5 is the final step to complete the original functional scope of `docs/masterplan.md`. -### Tasks: -1. **Restore Engine (`RestoreEngine.kt`)** - - Implement `restoreBackup(entry: BackupEntry, targetDir: File): RestoreResult`. - - Support restoring from both timestamped directory structures and `.zip` archives (extracting using `ZipInputStream`). - - Clean/delete existing target folders (`WTF` and/or `Interface`) safely before copy/extraction. - - Implement live progress reporting (`RestoreProgress` with total files, completed count, current file name). - - Ensure full cancellation support via Kotlin Coroutines. -2. **Safety & Validation Mechanics** - - Warn or guard if World of Warcraft is currently running during restoration. - - Verify backup integrity and destination disk space before starting destructive deletion. - - Create an automatic temporary safety snapshot of current `WTF`/`Interface` folders prior to overwrite. -3. **Restore ViewModel (`RestoreViewModel.kt`)** - - Expose `RestoreUiState` combining available backups list, selected entry, progress, and confirmation states. - - Manage restore lifecycle (start, cancel, dismiss, error handling). -4. **Restore Screen UI (`RestoreScreen.kt`)** - - Replace placeholder with scrollable list of existing backups displaying date/time, archive size, and format badge. - - Material 3 confirmation dialog detailing destructive overwrite. - - Progress overlay with `LinearProgressIndicator`, active file, and cancellation button. - - Empty state when no backups are present. -5. **App Wiring** - - Connect `Screen.RESTORE` in `App.kt` to `RestoreScreen`. - - Enable the "Restore" button on `StatusScreen.kt`. - --- -## 2. Testing Infrastructure & Automated Test Suite - -Currently, the project contains no automated test suite (`src/jvmTest` is empty). Adding tests will prevent regressions during future maintenance. - -### Test Targets: -1. **`BackupHistoryTest`**: - - Timestamp parsing across formats and edge cases. - - Backup listing and correct descending sort order. - - Retention policy pruning (ensuring only oldest backups exceeding count are deleted). - - Metrics computation for directory trees and ZIP files. -2. **`BackupEngineTest` & `RestoreEngineTest`**: - - File copy accuracy, recursive folder preservation, and attribute retention. - - ZIP compression and decompression fidelity. - - Cancellation responsiveness and cleanup of partial writes. -3. **`ConfigManagerTest`**: - - JSON serialization / deserialization defaults and edge cases. - - Thread-safe updates and reactive `StateFlow` emissions. -4. **`WoWLocationsTest`**: - - Install directory heuristics and resolution from root `World of Warcraft` to `_retail_` or `_classic_`. - - Missing folder detection and permission warnings. - ---- - -## 3. Build Automation & Release Pipeline (Completed) - -Automated multi-platform packaging and release pipeline implemented via GitHub Actions (`.github/workflows/release.yml`) and documented in `INSTALL.md` and `docs/plans/feature-6-release-pipeline.md`. - -### Implemented: -1. **GitHub Actions Release Pipeline** - - Automated semantic patch version computation and git tagging on push to `main` and `workflow_dispatch`. - - Matrix builds on macOS (`macos-latest`), Windows (`windows-latest`), and Linux (`ubuntu-latest`). -2. **Automated Distribution Packaging & GitHub Releases** - - Automated artifact generation and release publishing for `.dmg` (macOS), `.msi` (Windows), and `.deb` (Linux). - - Dynamic version injection via `-PappVersion` in `composeApp/build.gradle.kts`. -3. **Installation Documentation** - - Detailed `INSTALL.md` guide covering installation and security prompts (Gatekeeper / SmartScreen) across all platforms. - ---- - -## 4. Future Enhancements (Post-v1.0) +## Future Enhancements (Post-v1.0) 1. **Multi-Flavour & Account Profile Support** - Support backing up multiple game versions simultaneously (`_retail_`, `_classic_`, `_classic_era_`, `_ptr_`). diff --git a/docs/screenshots/hero.png b/docs/screenshots/hero.png new file mode 100644 index 0000000..be71afb Binary files /dev/null and b/docs/screenshots/hero.png differ diff --git a/docs/screenshots/wow-backup-hero.xcf b/docs/screenshots/wow-backup-hero.xcf new file mode 100644 index 0000000..acca288 Binary files /dev/null and b/docs/screenshots/wow-backup-hero.xcf differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9e629fd..b5a27d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,27 @@ [versions] -androidx-lifecycle = "2.10.0-alpha08" -composeMultiplatform = "1.11.0-alpha03" +androidx-lifecycle = "2.11.0" +composeMaterial3 = "1.9.0" +composeMultiplatform = "1.11.1" kotlin = "2.2.21" -kotlinx-coroutines = "1.10.2" -kotlinx-datetime = "0.6.2" -kotlinx-serialization = "1.10.0" +kotlinx-coroutines = "1.11.0" +kotlinx-datetime = "0.8.0" +kotlinx-serialization = "1.11.0" cardiologist = "0.8.0" -kotlin-logging = "7.0.3" -logback = "1.5.18" -materialKolor = "2.0.0" +kotlin-logging = "8.0.4" +logback = "1.5.38" +materialKolor = "4.0.5" [libraries] 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" } +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } +compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } +compose-components-uiToolingPreview = { module = "org.jetbrains.compose.components:components-ui-tooling-preview", version.ref = "composeMultiplatform" } 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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 19a6bde..c42672d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME