diff --git a/.gitea/issue_template/bug_report.yaml b/.gitea/issue_template/bug_report.yaml index bc436de..f8e95c4 100644 --- a/.gitea/issue_template/bug_report.yaml +++ b/.gitea/issue_template/bug_report.yaml @@ -62,7 +62,6 @@ body: options: - macOS - Windows - - Linux validations: required: true @@ -81,7 +80,6 @@ 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 b2360d9..5076c0e 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 (Experimental)**: \`.deb\` package (Debian / Ubuntu) + - **Linux**: \`.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,33 +206,3 @@ 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 1e39ff3..c232741 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,20 +1,46 @@ # Installing WoW Backup -## Download - -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** (Experimental) | Debian Package (DEB) | `wow-backup__amd64.deb` | - -_Note: The Linux version is untested and experimental. Use at your own risk._ +This guide provides step-by-step instructions for installing and running **WoW Backup** on macOS, Windows, and Linux. --- -## Windows +## 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. +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 1. Download the `.msi` installer from the latest release. 2. Double-click the `.msi` file to start the installation wizard. @@ -28,59 +54,7 @@ If Windows Defender SmartScreen flags the installer (*"Windows protected your PC --- -## 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) +## Linux Installation 1. Download the `.deb` package from the latest release. 2. Open your terminal in the download folder and install the package: @@ -94,3 +68,17 @@ To launch the app: ``` 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 5fd72ff..3db5374 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,22 @@ -![WoW Backup](docs/screenshots/hero.png) +# WoW Backup -## Installation & Download +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. -Pre-built binaries for **macOS**, **Windows**, and **Linux** are provided on each release. +## Overview -👉 **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. --- @@ -30,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 @@ -69,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 [UNLICENSE](UNLICENSE) 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/UNLICENSE b/UNLICENSE deleted file mode 100644 index fdddb29..0000000 --- a/UNLICENSE +++ /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/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c7891ab..f0bb771 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -12,12 +12,12 @@ kotlin { sourceSets { commonMain.dependencies { - 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(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) } @@ -31,22 +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 = tasks.register("generateBuildConfig") { - description = "Generates BuildConfig.kt" +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") @@ -71,24 +65,16 @@ compose.desktop { application { mainClass = "com.rukira.wowbackup.MainKt" - jvmArgs("-Dapple.awt.enableTemplateImages=true", "-Dapple.awt.UIElement=true") + jvmArgs("-Dapple.awt.enableTemplateImages=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 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/main.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt index aee168b..a9e0d8b 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt @@ -28,9 +28,6 @@ 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 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/config/ConfigScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt index 95dd10d..63922c0 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,234 +63,214 @@ fun ConfigScreen( var showForceBackupDialog by remember { mutableStateOf(false) } val scrollState = rememberScrollState() - 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) + Box(modifier = Modifier.fillMaxSize()) { + 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 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 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, - ) - } + val interfaceStatus = when { + validation.interfaceReadable -> "Interface folder: found" + validation.hasInterface -> "Interface folder: found (not readable)" + else -> "Interface folder: not found" } - - 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( - "Theme changes apply immediately.", + "$wtfStatus | $interfaceStatus", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + } - 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), - ), + 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) }, ) } - // === Sticky Footer === + 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)) HorizontalDivider() + Spacer(Modifier.height(8.dp)) Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { @@ -323,6 +303,20 @@ 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 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/ui/theme/Theme.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt index 0f5fd4d..9b4c7e7 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, - isDark = useDarkTheme, + useDarkTheme = 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 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/docs/plans/feature-1-system-tray.md b/docs/plans/feature-1-system-tray.md index 826321a..699456f 100644 --- a/docs/plans/feature-1-system-tray.md +++ b/docs/plans/feature-1-system-tray.md @@ -84,31 +84,18 @@ 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 and Dock hiding (LSUIElement) +### Step 6: macOS JVM args **Files to modify:** - `composeApp/build.gradle.kts` — add to desktop application config: ```kotlin compose.desktop { application { - jvmArgs("-Dapple.awt.enableTemplateImages=true", "-Dapple.awt.UIElement=true") - - nativeDistributions { - macOS { - infoPlist { - extraKeysRawXml = """ - LSUIElement - - """.trimIndent() - } - } - } + jvmArgs("-Dapple.awt.enableTemplateImages=true") } } ``` -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 5afa40c..bcbbaec 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,13 +4,75 @@ This document outlines the current state of the project, remaining core features --- -## Immediate Priority: Feature 5 (Restore System) +## 1. 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`. + --- -## Future Enhancements (Post-v1.0) +## 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) 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 deleted file mode 100644 index be71afb..0000000 Binary files a/docs/screenshots/hero.png and /dev/null differ diff --git a/docs/screenshots/wow-backup-hero.xcf b/docs/screenshots/wow-backup-hero.xcf deleted file mode 100644 index acca288..0000000 Binary files a/docs/screenshots/wow-backup-hero.xcf and /dev/null differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b5a27d6..9e629fd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,27 +1,19 @@ [versions] -androidx-lifecycle = "2.11.0" -composeMaterial3 = "1.9.0" -composeMultiplatform = "1.11.1" +androidx-lifecycle = "2.10.0-alpha08" +composeMultiplatform = "1.11.0-alpha03" kotlin = "2.2.21" -kotlinx-coroutines = "1.11.0" -kotlinx-datetime = "0.8.0" -kotlinx-serialization = "1.11.0" +kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.2" +kotlinx-serialization = "1.10.0" cardiologist = "0.8.0" -kotlin-logging = "8.0.4" -logback = "1.5.38" -materialKolor = "4.0.5" +kotlin-logging = "7.0.3" +logback = "1.5.18" +materialKolor = "2.0.0" [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 c42672d..19a6bde 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.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME