Compare commits
31 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fb6a25be | ||
|
|
2eeebee36d | ||
|
|
616a7a9a95 | ||
|
|
d0887d88ba | ||
|
|
ae845d422c | ||
|
|
5c1b8b3cc8 | ||
|
|
3e2fcf87a8 | ||
|
|
03b90e2afe | ||
|
|
58f493e561 | ||
|
|
629ae6899a | ||
|
|
f6d754ff0a | ||
|
|
2ccdb02ba7 | ||
|
|
0784d7ce3f | ||
|
|
9a9b42a88c | ||
|
|
8bc24446fa | ||
|
|
fc4451bfbc | ||
|
|
6e5fd227bf | ||
|
|
e873f14a7b | ||
|
|
b7bff1a7bd | ||
|
|
7056758728 | ||
|
|
65962f3581 | ||
|
|
472654180a | ||
|
|
3a7a92800b | ||
|
|
892448c57f | ||
|
|
5be2247cb4 | ||
|
|
5b4798b7b2 | ||
|
|
88e64b25c8 | ||
|
|
0ea2067e42 | ||
|
|
033febece6 | ||
|
|
12e6a6f94b | ||
|
|
31a3cbc327 |
26 changed files with 1032 additions and 392 deletions
|
|
@ -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.
|
||||
|
|
|
|||
35
.github/workflows/release.yml
vendored
35
.github/workflows/release.yml
vendored
|
|
@ -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)."
|
||||
|
|
@ -198,10 +198,41 @@ jobs:
|
|||
filename=$(basename "$file")
|
||||
echo "Uploading asset ${filename} to Forgejo release ${RELEASE_ID}..."
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://git.asarius.site/api/v1/repos/rukira/wow-backup/releases/${RELEASE_ID}/assets?name=${filename}" \
|
||||
"https://git.asarius.site/api/v1/repos/rukira/wow-backup/releases/${RELEASE_ID}/assets" \
|
||||
--url-query "name=${filename}" \
|
||||
-H "Authorization: token ${FORGEJO_PAT}" \
|
||||
-H "Accept: application/json" \
|
||||
-F "attachment=@${file}"
|
||||
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."
|
||||
|
|
|
|||
112
INSTALL.md
112
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-<version>.dmg` |
|
||||
| **Windows** | Windows Installer (MSI) | `WoW Backup-<version>.msi` |
|
||||
| **Linux** | Debian Package (DEB) | `wow-backup_<version>_amd64.deb` |
|
||||
| Platform | Package Format | Installer File Pattern |
|
||||
|--------------------------|---|---|
|
||||
| **macOS** | DMG Disk Image | `WoW Backup-<version>.dmg` |
|
||||
| **Windows** | Windows Installer (MSI) | `WoW Backup-<version>.msi` |
|
||||
| **Linux** (Experimental) | Debian Package (DEB) | `wow-backup_<version>_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.
|
||||
|
|
|
|||
64
README.md
64
README.md
|
|
@ -1,22 +1,28 @@
|
|||
# WoW Backup
|
||||

|
||||
|
||||
A lightweight, native-feeling desktop application built with Kotlin and Compose Multiplatform to automatically backup and restore World of Warcraft configuration data (WTF and Interface directories) on macOS and Windows.
|
||||
## 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.
|
||||
|
|
|
|||
24
UNLICENSE
Normal file
24
UNLICENSE
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
|
|
@ -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 = """
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
windows {
|
||||
|
|
|
|||
|
|
@ -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<ILoggingEvent>().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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UpdateInfo?>(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
updateInfo.value = updateService.checkForUpdate()
|
||||
} catch (e: Exception) {
|
||||
// Silently handle any uncaught exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only recomputes when backupPath or lastBackupResult changes — not on every progress tick
|
||||
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())
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ fun WoWBackupTheme(
|
|||
|
||||
DynamicMaterialTheme(
|
||||
seedColor = accentColor.seedColor,
|
||||
useDarkTheme = useDarkTheme,
|
||||
isDark = useDarkTheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package com.rukira.wowbackup.update
|
||||
|
||||
data class SemVer(
|
||||
val major: Int,
|
||||
val minor: Int = 0,
|
||||
val patch: Int = 0,
|
||||
val preRelease: String? = null,
|
||||
) : Comparable<SemVer> {
|
||||
|
||||
override fun compareTo(other: SemVer): Int {
|
||||
if (this.major != other.major) return this.major.compareTo(other.major)
|
||||
if (this.minor != other.minor) return this.minor.compareTo(other.minor)
|
||||
if (this.patch != other.patch) return this.patch.compareTo(other.patch)
|
||||
|
||||
// A version without a pre-release is greater than one with a pre-release
|
||||
return when {
|
||||
this.preRelease == null && other.preRelease == null -> 0
|
||||
this.preRelease == null && other.preRelease != null -> 1
|
||||
this.preRelease != null && other.preRelease == null -> -1
|
||||
else -> this.preRelease!!.compareTo(other.preRelease!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun isNewerThan(other: SemVer): Boolean = this > other
|
||||
|
||||
override fun toString(): String {
|
||||
val base = "$major.$minor.$patch"
|
||||
return if (preRelease != null) "$base-$preRelease" else base
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parseOrNull(version: String?): SemVer? {
|
||||
if (version.isNullOrBlank()) return null
|
||||
|
||||
val cleaned = version.trim().removePrefix("v").removePrefix("V").trim()
|
||||
if (cleaned.isEmpty()) return null
|
||||
|
||||
val parts = cleaned.split("-", limit = 2)
|
||||
val core = parts[0]
|
||||
val preRelease = parts.getOrNull(1)?.takeIf { it.isNotBlank() }
|
||||
|
||||
val numParts = core.split(".")
|
||||
if (numParts.isEmpty() || numParts.size > 3) return null
|
||||
|
||||
val major = numParts[0].toIntOrNull() ?: return null
|
||||
val minor = if (numParts.size > 1) numParts[1].toIntOrNull() ?: return null else 0
|
||||
val patch = if (numParts.size > 2) numParts[2].toIntOrNull() ?: return null else 0
|
||||
|
||||
return SemVer(major = major, minor = minor, patch = patch, preRelease = preRelease)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.rukira.wowbackup.update
|
||||
|
||||
import com.rukira.wowbackup.BuildConfig
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Serializable
|
||||
internal data class ReleaseDto(
|
||||
@SerialName("tag_name") val tagName: String = "",
|
||||
val name: String? = null,
|
||||
@SerialName("html_url") val htmlUrl: String? = null,
|
||||
)
|
||||
|
||||
data class UpdateInfo(
|
||||
val latestVersion: String,
|
||||
val releaseUrl: String,
|
||||
)
|
||||
|
||||
interface UpdateService {
|
||||
suspend fun checkForUpdate(currentVersion: String = BuildConfig.VERSION): UpdateInfo?
|
||||
}
|
||||
|
||||
class UpdateChecker(
|
||||
private val apiUrl: String = DEFAULT_API_URL,
|
||||
private val fallbackUrl: String = DEFAULT_FALLBACK_URL,
|
||||
private val httpClient: HttpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build(),
|
||||
) : UpdateService {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(apiUrl))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "WoWBackup/${BuildConfig.VERSION}")
|
||||
.GET()
|
||||
.build()
|
||||
|
||||
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
if (response.statusCode() != 200) {
|
||||
logger.warn { "Update check received non-200 status code: ${response.statusCode()}" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
if (body.isNullOrBlank()) {
|
||||
logger.warn { "Update check received empty response body" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val release = json.decodeFromString<ReleaseDto>(body)
|
||||
val remoteSemVer = SemVer.parseOrNull(release.tagName) ?: run {
|
||||
logger.warn { "Failed to parse remote release tag: '${release.tagName}'" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val currentSemVer = SemVer.parseOrNull(currentVersion) ?: run {
|
||||
logger.warn { "Failed to parse current version: '$currentVersion'" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
if (remoteSemVer.isNewerThan(currentSemVer)) {
|
||||
val releaseUrl = release.htmlUrl?.takeIf { it.isNotBlank() } ?: fallbackUrl
|
||||
val cleanTag = release.tagName.trim().removePrefix("v").removePrefix("V")
|
||||
logger.info { "New update available: v$cleanTag (current: v$currentVersion). URL: $releaseUrl" }
|
||||
UpdateInfo(
|
||||
latestVersion = cleanTag,
|
||||
releaseUrl = releaseUrl,
|
||||
)
|
||||
} else {
|
||||
logger.debug { "App is up to date: current=$currentVersion, remote=${release.tagName}" }
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "Failed to check for updates: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_API_URL = "https://git.asarius.site/api/v1/repos/rukira/wow-backup/releases/latest"
|
||||
const val DEFAULT_FALLBACK_URL = "https://git.asarius.site/rukira/wow-backup/releases"
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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'")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = """
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
""".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
|
||||
|
|
|
|||
|
|
@ -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_`).
|
||||
|
|
|
|||
BIN
docs/screenshots/hero.png
Normal file
BIN
docs/screenshots/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
BIN
docs/screenshots/wow-backup-hero.xcf
Normal file
BIN
docs/screenshots/wow-backup-hero.xcf
Normal file
Binary file not shown.
|
|
@ -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" }
|
||||
|
|
|
|||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue