Compare commits
19 commits
ebd1e55277
...
b0aaeb940b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0aaeb940b | ||
|
|
2c7c530e22 | ||
|
|
c8d85912b8 | ||
|
|
cf9840c1b6 | ||
|
|
ed1c00ad11 | ||
|
|
482089cd62 | ||
|
|
cdda6c9156 | ||
|
|
6fe133766e | ||
|
|
9cb66f888d | ||
|
|
613ceb01b7 | ||
|
|
02cbf20946 | ||
|
|
6e15676f34 | ||
|
|
46587f7d28 | ||
|
|
df0640f857 | ||
|
|
d211b34405 | ||
|
|
616fd21872 | ||
|
|
cfd093af72 | ||
|
|
51bc86ec6d | ||
|
|
c73f210b83 |
24 changed files with 996 additions and 362 deletions
|
|
@ -62,6 +62,7 @@ body:
|
||||||
options:
|
options:
|
||||||
- macOS
|
- macOS
|
||||||
- Windows
|
- Windows
|
||||||
|
- Linux
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
@ -80,6 +81,7 @@ body:
|
||||||
Logs help us understand what went wrong behind the scenes. Here's how to find them:
|
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`
|
**macOS:** Open Finder, press **Cmd+Shift+G**, and paste: `~/Library/Application Support/WoWBackup/logs`
|
||||||
|
|
||||||
**Windows:** Press **Win+R**, and paste: `%APPDATA%\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.
|
You can also open this folder from the app: go to **Settings** and click the **Open Logs** button at the bottom.
|
||||||
|
|
|
||||||
68
.github/workflows/release.yml
vendored
68
.github/workflows/release.yml
vendored
|
|
@ -99,7 +99,7 @@ jobs:
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
name: Create GitHub Release
|
name: Create GitHub & Forgejo Releases
|
||||||
needs:
|
needs:
|
||||||
- bump-version
|
- bump-version
|
||||||
- package
|
- package
|
||||||
|
|
@ -140,3 +140,69 @@ jobs:
|
||||||
gh release create "${RELEASE_TAG}" dist/* \
|
gh release create "${RELEASE_TAG}" dist/* \
|
||||||
--title "WoW Backup ${RELEASE_TAG}" \
|
--title "WoW Backup ${RELEASE_TAG}" \
|
||||||
--notes "${RELEASE_BODY}"
|
--notes "${RELEASE_BODY}"
|
||||||
|
|
||||||
|
- name: Publish Forgejo Release
|
||||||
|
env:
|
||||||
|
FORGEJO_PAT: ${{ secrets.FORGEJO_PAT }}
|
||||||
|
RELEASE_TAG: ${{ needs.bump-version.outputs.tag }}
|
||||||
|
RELEASE_VERSION: ${{ needs.bump-version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
RELEASE_BODY="### WoW Backup ${RELEASE_TAG}
|
||||||
|
|
||||||
|
Automated release build for **WoW Backup v${RELEASE_VERSION}**.
|
||||||
|
|
||||||
|
#### Available Packages
|
||||||
|
- **macOS**: \`.dmg\` installer (Apple Silicon & Intel)
|
||||||
|
- **Windows**: \`.msi\` Windows Installer
|
||||||
|
- **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)."
|
||||||
|
|
||||||
|
echo "Publishing release ${RELEASE_TAG} to Forgejo (git.asarius.site)..."
|
||||||
|
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg tag "$RELEASE_TAG" \
|
||||||
|
--arg name "WoW Backup ${RELEASE_TAG}" \
|
||||||
|
--arg body "$RELEASE_BODY" \
|
||||||
|
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||||
|
|
||||||
|
CREATE_RESPONSE=$(curl -sS -X POST \
|
||||||
|
"https://git.asarius.site/api/v1/repos/rukira/wow-backup/releases" \
|
||||||
|
-H "Authorization: token ${FORGEJO_PAT}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-d "$PAYLOAD" || true)
|
||||||
|
|
||||||
|
RELEASE_ID=$(echo "$CREATE_RESPONSE" | jq -r '.id // empty')
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||||
|
echo "Release creation did not return an ID, checking if release already exists for tag ${RELEASE_TAG}..."
|
||||||
|
GET_RESPONSE=$(curl -sS -X GET \
|
||||||
|
"https://git.asarius.site/api/v1/repos/rukira/wow-backup/releases/tags/${RELEASE_TAG}" \
|
||||||
|
-H "Authorization: token ${FORGEJO_PAT}" \
|
||||||
|
-H "Accept: application/json" || true)
|
||||||
|
RELEASE_ID=$(echo "$GET_RESPONSE" | jq -r '.id // empty')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||||
|
echo "::error::Failed to create or find Forgejo release."
|
||||||
|
echo "Response: $CREATE_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Forgejo Release ID: ${RELEASE_ID}"
|
||||||
|
|
||||||
|
for file in dist/*; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
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" \
|
||||||
|
--url-query "name=${filename}" \
|
||||||
|
-H "Authorization: token ${FORGEJO_PAT}" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-F "attachment=@${file}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Forgejo release published successfully."
|
||||||
|
|
|
||||||
52
INSTALL.md
52
INSTALL.md
|
|
@ -1,22 +1,20 @@
|
||||||
# Installing WoW Backup
|
# Installing WoW Backup
|
||||||
|
|
||||||
This guide provides step-by-step instructions for installing and running **WoW Backup** on macOS, Windows, and Linux.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Download the appropriate installer for your platform from the **[GitHub Releases](https://github.com/rukira/wow-backup/releases)** page:
|
Download the appropriate installer for your platform from the **[latest release](https://git.asarius.site/rukira/wow-backup/releases/latest)**:
|
||||||
|
|
||||||
| Platform | Package Format | Installer File Pattern |
|
| Platform | Package Format | Installer File Pattern |
|
||||||
|---|---|---|
|
|--------------------------|---|---|
|
||||||
| **macOS** | DMG Disk Image | `WoW Backup-<version>.dmg` |
|
| **macOS** | DMG Disk Image | `WoW Backup-<version>.dmg` |
|
||||||
| **Windows** | Windows Installer (MSI) | `WoW Backup-<version>.msi` |
|
| **Windows** | Windows Installer (MSI) | `WoW Backup-<version>.msi` |
|
||||||
| **Linux** | Debian Package (DEB) | `wow-backup_<version>_amd64.deb` |
|
| **Linux** (Experimental) | Debian Package (DEB) | `wow-backup_<version>_amd64.deb` |
|
||||||
|
|
||||||
|
_Note: The Linux version is untested and experimental. Use at your own risk._
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## macOS Installation
|
## macOS
|
||||||
|
|
||||||
1. Download the `.dmg` file from the latest release.
|
1. Download the `.dmg` file from the latest release.
|
||||||
2. Double-click the `.dmg` file to mount the disk image.
|
2. Double-click the `.dmg` file to mount the disk image.
|
||||||
|
|
@ -27,20 +25,14 @@ Download the appropriate installer for your platform from the **[GitHub Releases
|
||||||
Because WoW Backup is distributed without Apple Developer ID code-signing notarization, macOS Gatekeeper may show a warning on first launch (*"WoW Backup cannot be opened because the developer cannot be verified"* or *"Apple could not verify that WoW Backup is free of malware"*).
|
Because WoW Backup is distributed without Apple Developer ID code-signing notarization, macOS Gatekeeper may show a warning on first launch (*"WoW Backup cannot be opened because the developer cannot be verified"* or *"Apple could not verify that WoW Backup is free of malware"*).
|
||||||
|
|
||||||
To launch the app for the first time:
|
To launch the app for the first time:
|
||||||
|
1. Open **System Settings**
|
||||||
- **Option A (Finder)**:
|
2. Navigate to **Security & Privacy**.
|
||||||
1. Open Finder and navigate to `/Applications`.
|
3. Click **Open Anyway** under **Allow apps downloaded from**.
|
||||||
2. Right-click (or Control-click) on **WoW Backup.app** and choose **Open**.
|
4. Restart WoW Backup.
|
||||||
3. In the security popup, click **Open**.
|
|
||||||
- **Option B (Terminal)**:
|
|
||||||
Run the following command in Terminal to clear the quarantine attribute:
|
|
||||||
```shell
|
|
||||||
xattr -d com.apple.quarantine "/Applications/WoW Backup.app"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Windows Installation
|
## Windows
|
||||||
|
|
||||||
1. Download the `.msi` installer from the latest release.
|
1. Download the `.msi` installer from the latest release.
|
||||||
2. Double-click the `.msi` file to start the installation wizard.
|
2. Double-click the `.msi` file to start the installation wizard.
|
||||||
|
|
@ -54,7 +46,7 @@ If Windows Defender SmartScreen flags the installer (*"Windows protected your PC
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Linux Installation
|
## Linux (Experimental)
|
||||||
|
|
||||||
1. Download the `.deb` package from the latest release.
|
1. Download the `.deb` package from the latest release.
|
||||||
2. Open your terminal in the download folder and install the package:
|
2. Open your terminal in the download folder and install the package:
|
||||||
|
|
@ -68,17 +60,3 @@ If Windows Defender SmartScreen flags the installer (*"Windows protected your PC
|
||||||
```
|
```
|
||||||
3. Launch **WoW Backup** from your desktop application launcher or run `wowbackup` in the terminal.
|
3. Launch **WoW Backup** from your desktop application launcher or run `wowbackup` in the terminal.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## First-Time Setup & Overview
|
|
||||||
|
|
||||||
Once started, WoW Backup runs discreetly in the system tray / menu bar:
|
|
||||||
|
|
||||||
1. **Accessing Dashboard**: Click the tray icon in the macOS menu bar or Windows/Linux system tray to open the application window.
|
|
||||||
2. **Settings Configuration**:
|
|
||||||
- Navigate to **Settings** (gear icon).
|
|
||||||
- Set your **World of Warcraft Installation Folder** (e.g. `_retail_` or `_classic_`).
|
|
||||||
- Select your **Backup Destination Folder**.
|
|
||||||
- Set your preferred **Daily Backup Time**, **Retention Policy** (number of backups to keep), and **Compression Mode** (`ZIP` or folder replication).
|
|
||||||
- Optionally enable **Launch at Startup** so backups occur automatically without manual intervention.
|
|
||||||
3. **Lifecycle**: Closing the main window hides it to the tray while keeping the scheduler running. To quit the application completely, select **Quit** from the tray menu.
|
|
||||||
|
|
|
||||||
24
LICENSE
Normal file
24
LICENSE
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>
|
||||||
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
|
## Key Features
|
||||||
|
|
||||||
- 🛡️ **System Tray Native Life Cycle**: Lives in the menu bar / system tray with custom template icons matching light/dark OS appearances. Closing the window hides to tray; quitting is explicit.
|
|
||||||
- ⚙️ **Smart Auto-Configuration**: Automatically detects standard WoW install locations (supporting retail and classic directories) and validates folder permissions.
|
- ⚙️ **Smart Auto-Configuration**: Automatically detects standard WoW install locations (supporting retail and classic directories) and validates folder permissions.
|
||||||
- ⏰ **Automated & Scheduled Backups**: Background scheduler powered by Cardiologist ensures dependable daily backups at a user-defined time.
|
- ⏰ **Automated & Scheduled Backups**: Background scheduler ensures regular backups at a user-defined time.
|
||||||
- 📦 **Compression & History Management**: Supports both folder replication and ZIP compression, with automatic pruning of backups beyond the configured retention limit (1–30 backups).
|
- 📦 **Compression & History Management**: Supports both folder replication and ZIP compression, with automatic pruning of backups beyond the configured limit.
|
||||||
- 🎮 **Game State Detection**: Checks for active WoW processes before running backups to prevent file-locking conflicts, with optional "force backup" override and safety confirmation.
|
- 🎮 **Game State Detection**: Checks for running WoW processes before running backups to prevent file-locking conflicts.
|
||||||
- 🔔 **Native OS Notifications**: Notifies the user when backups start, succeed, fail, or are skipped due to game execution.
|
- 🔔 **Native OS Notifications**: Notifies the user when backups start, succeed, fail, or are skipped.
|
||||||
- 🚀 **Launch at Startup**: Integrated with macOS LaunchAgents (`~/Library/LaunchAgents/`) and Windows Registry (`HKCU\...\Run`).
|
- 🚀 **Launch at Startup**: Integrated with macOS LaunchAgents (`~/Library/LaunchAgents/`) and Windows Registry (`HKCU\...\Run`).
|
||||||
- 🎨 **Modern Theming & Customization**: Dynamic Material 3 theming supporting System/Light/Dark modes and configurable accent colors (via MaterialKolor).
|
- 🎨 **Theming & Customization**: Dynamic theming supporting Light/Dark mode, with configurable accent colors.
|
||||||
- 🔄 **Restore System (In Progress)**: Interactive restore interface to safely roll back WTF and Interface configurations.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Application Data Locations
|
||||||
|
The app uses a configuration file to store user preferences. This file and runtime logs are stored in the following locations:
|
||||||
|
- **macOS**: `~/Library/Application Support/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
|
||||||
|
- **Windows**: `%APPDATA%\WoWBackup\` (Logs: `...\logs\wowbackup.log`)
|
||||||
|
- **Linux**: `~/.config/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -24,22 +30,14 @@ World of Warcraft stores all UI configurations, macros, keybindings, and addon s
|
||||||
|
|
||||||
- **Language & Runtime**: Kotlin Multiplatform targeting Desktop (JVM, Java 17+)
|
- **Language & Runtime**: Kotlin Multiplatform targeting Desktop (JVM, Java 17+)
|
||||||
- **UI Framework**: JetBrains Compose Multiplatform with Material 3 Design
|
- **UI Framework**: JetBrains Compose Multiplatform with Material 3 Design
|
||||||
- **Architecture**: MVI / MVVM with Kotlin Coroutines and StateFlow
|
- **Architecture**: MVVM with Kotlin Coroutines
|
||||||
- **Scheduling**: `io.github.kevincianfarini.cardiologist` for drift-free daily execution
|
- **Scheduling**: `io.github.kevincianfarini.cardiologist`
|
||||||
- **Logging**: `logback-classic` + `kotlin-logging-jvm` with daily rotation and size archiving in app data directories
|
- **Logging**: `logback-classic` + `kotlin-logging-jvm` with daily rotation and archiving
|
||||||
- **Serialization**: `kotlinx-serialization-json` for typed JSON configuration persistence
|
- **Serialization**: Settings stored in a JSON file using `kotlinx-serialization-json`
|
||||||
- **Theming**: `com.materialkolor:material-kolor` for dynamic palette generation
|
- **Theming**: `com.materialkolor:material-kolor` for dynamic palette generation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation & Download
|
|
||||||
|
|
||||||
Pre-built binaries for macOS, Windows, and Linux are automatically packaged and published on each release.
|
|
||||||
|
|
||||||
👉 **See [INSTALL.md](INSTALL.md)** for download links, step-by-step installation instructions, Gatekeeper/SmartScreen bypass guidance, and initial setup notes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build & Run
|
## Build & Run
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
@ -71,22 +69,6 @@ Pre-built binaries for macOS, Windows, and Linux are automatically packaged and
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Implementation Status
|
## License
|
||||||
|
|
||||||
| Feature | Plan Document | Status | Description |
|
This project is released into the public domain. See the [LICENSE](LICENSE) file or [unlicense.org](https://unlicense.org/) for details.
|
||||||
|---|---|---|---|
|
|
||||||
| **0. Foundation** | `docs/plans/feature-0-foundation.md` | ✅ **Completed** | Architecture, config persistence, logging, platform abstractions |
|
|
||||||
| **1. System Tray** | `docs/plans/feature-1-system-tray.md` | ✅ **Completed** | Tray icon, menu, tray-first lifecycle, window alignment |
|
|
||||||
| **2. Configuration** | `docs/plans/feature-2-configuration.md` | ✅ **Completed** | Full settings UI, path validation, native file dialogs, startup manager |
|
|
||||||
| **3. Backup Engine** | `docs/plans/feature-3-backup.md` | ✅ **Completed** | Parallel copy, ZIP compression, scheduler, history pruning, notifications |
|
|
||||||
| **4. Status Screen** | `docs/plans/feature-4-status.md` | ✅ **Completed** | Live status dashboard, progress indicator, manual backup trigger, shortcuts |
|
|
||||||
| **5. Restore Screen** | `docs/plans/feature-5-restore.md` | 🚧 **Pending** | Backup list browser, ZIP extraction, confirmation flow, safety checks |
|
|
||||||
| **6. Release Pipeline** | `docs/plans/feature-6-release-pipeline.md` | ✅ **Completed** | GitHub Actions multi-platform release pipeline & packaging automation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Application Data Locations
|
|
||||||
|
|
||||||
- **macOS**: `~/Library/Application Support/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
|
|
||||||
- **Windows**: `%APPDATA%\WoWBackup\` (Logs: `...\logs\wowbackup.log`)
|
|
||||||
- **Linux**: `~/.config/WoWBackup/` (Logs: `.../logs/wowbackup.log`)
|
|
||||||
|
|
|
||||||
|
|
@ -31,16 +31,21 @@ kotlin {
|
||||||
implementation(libs.logback.classic)
|
implementation(libs.logback.classic)
|
||||||
implementation(libs.material.kolor)
|
implementation(libs.material.kolor)
|
||||||
}
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutinesTest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val appVersion = (project.findProperty("appVersion") as? String)?.takeIf { it.isNotBlank() } ?: "1.0.0"
|
val appVersion = providers.gradleProperty("appVersion").orNull?.takeIf { it.isNotBlank() } ?: "0.0.1"
|
||||||
|
|
||||||
val generatedSrcDir = layout.buildDirectory.dir("generated/src/jvmMain/kotlin")
|
val generatedSrcDir = layout.buildDirectory.dir("generated/src/jvmMain/kotlin")
|
||||||
|
|
||||||
val generateBuildConfig by tasks.registering {
|
val generateBuildConfig by tasks.registering {
|
||||||
val outputDir = generatedSrcDir
|
val outputDir = generatedSrcDir
|
||||||
val version = appVersion
|
val version = appVersion
|
||||||
|
inputs.property("version", version)
|
||||||
outputs.dir(outputDir)
|
outputs.dir(outputDir)
|
||||||
doLast {
|
doLast {
|
||||||
val dir = outputDir.get().asFile.resolve("com/rukira/wowbackup")
|
val dir = outputDir.get().asFile.resolve("com/rukira/wowbackup")
|
||||||
|
|
@ -65,16 +70,24 @@ compose.desktop {
|
||||||
application {
|
application {
|
||||||
mainClass = "com.rukira.wowbackup.MainKt"
|
mainClass = "com.rukira.wowbackup.MainKt"
|
||||||
|
|
||||||
jvmArgs("-Dapple.awt.enableTemplateImages=true")
|
jvmArgs("-Dapple.awt.enableTemplateImages=true", "-Dapple.awt.UIElement=true")
|
||||||
|
|
||||||
nativeDistributions {
|
nativeDistributions {
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||||
packageName = "WoW Backup"
|
packageName = "WoW Backup"
|
||||||
packageVersion = appVersion
|
packageVersion = appVersion
|
||||||
|
|
||||||
|
modules("java.naming", "java.sql", "java.instrument", "jdk.unsupported")
|
||||||
|
|
||||||
macOS {
|
macOS {
|
||||||
bundleID = "com.rukira.wowbackup"
|
bundleID = "com.rukira.wowbackup"
|
||||||
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
|
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
|
||||||
|
infoPlist {
|
||||||
|
extraKeysRawXml = """
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
windows {
|
windows {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,12 @@ import java.io.File
|
||||||
|
|
||||||
object LoggingSetup {
|
object LoggingSetup {
|
||||||
|
|
||||||
|
private var initialized = false
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
|
if (initialized) return
|
||||||
|
initialized = true
|
||||||
|
|
||||||
val logDir = AppDirectories.logsDir
|
val logDir = AppDirectories.logsDir
|
||||||
val logFile = File(logDir, "wowbackup.log")
|
val logFile = File(logDir, "wowbackup.log")
|
||||||
|
|
||||||
|
|
@ -26,7 +31,7 @@ object LoggingSetup {
|
||||||
|
|
||||||
val rollingPolicy = SizeAndTimeBasedRollingPolicy<ILoggingEvent>().apply {
|
val rollingPolicy = SizeAndTimeBasedRollingPolicy<ILoggingEvent>().apply {
|
||||||
this.context = context
|
this.context = context
|
||||||
fileNamePattern = "${logDir}/wowbackup.%d{yyyy-MM-dd}.%i.log.gz"
|
fileNamePattern = "${logDir.absolutePath}/wowbackup.%d{yyyy-MM-dd}.%i.log.gz"
|
||||||
setMaxFileSize(FileSize.valueOf("10MB"))
|
setMaxFileSize(FileSize.valueOf("10MB"))
|
||||||
maxHistory = 30
|
maxHistory = 30
|
||||||
setTotalSizeCap(FileSize.valueOf("100MB"))
|
setTotalSizeCap(FileSize.valueOf("100MB"))
|
||||||
|
|
@ -46,5 +51,10 @@ object LoggingSetup {
|
||||||
|
|
||||||
val rootLogger = context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)
|
val rootLogger = context.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)
|
||||||
rootLogger.addAppender(fileAppender)
|
rootLogger.addAppender(fileAppender)
|
||||||
|
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
val uncaughtLogger = LoggerFactory.getLogger("UncaughtExceptionHandler")
|
||||||
|
uncaughtLogger.error("Uncaught exception on thread [${thread.name}]", throwable)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ import java.awt.GraphicsEnvironment
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
fun main() {
|
fun main() {
|
||||||
|
if (Platform.current == OS.Mac) {
|
||||||
|
System.setProperty("apple.awt.UIElement", "true")
|
||||||
|
}
|
||||||
LoggingSetup.init()
|
LoggingSetup.init()
|
||||||
logger.info { "WoW Backup starting" }
|
logger.info { "WoW Backup starting" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.rukira.wowbackup.platform
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import java.awt.Desktop
|
import java.awt.Desktop
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
|
@ -15,4 +16,16 @@ object DesktopActions {
|
||||||
logger.error(e) { "Failed to open folder: $path" }
|
logger.error(e) { "Failed to open folder: $path" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openUrl(url: String) {
|
||||||
|
try {
|
||||||
|
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||||
|
Desktop.getDesktop().browse(URI(url))
|
||||||
|
} else {
|
||||||
|
logger.warn { "Desktop browsing is not supported on this platform" }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error(e) { "Failed to open URL: $url" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,214 +63,234 @@ fun ConfigScreen(
|
||||||
var showForceBackupDialog by remember { mutableStateOf(false) }
|
var showForceBackupDialog by remember { mutableStateOf(false) }
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.weight(1f)
|
||||||
.verticalScroll(scrollState)
|
.fillMaxWidth(),
|
||||||
.padding(24.dp),
|
) {
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
Column(
|
||||||
) {
|
modifier = Modifier
|
||||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
||||||
// === WoW Installation ===
|
// === WoW Installation ===
|
||||||
SectionHeader("WoW Installation")
|
SectionHeader("WoW Installation")
|
||||||
|
|
||||||
PathField(
|
PathField(
|
||||||
label = "WoW install location",
|
label = "WoW install location",
|
||||||
value = config.wowInstallPath ?: "",
|
value = config.wowInstallPath ?: "",
|
||||||
error = errors["wowPath"],
|
error = errors["wowPath"],
|
||||||
onBrowse = {
|
onBrowse = {
|
||||||
val dir = pickFolder("Select WoW Install Folder", config.wowInstallPath?.let { File(it) })
|
val dir = pickFolder("Select WoW Install Folder", config.wowInstallPath?.let { File(it) })
|
||||||
if (dir != null) viewModel.updateWoWPath(dir.absolutePath)
|
if (dir != null) viewModel.updateWoWPath(dir.absolutePath)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// WoW path warnings (e.g. missing WTF/Interface, permission issues)
|
// WoW path warnings (e.g. missing WTF/Interface, permission issues)
|
||||||
uiState.warnings["wowPath"]?.forEach { warning ->
|
uiState.warnings["wowPath"]?.forEach { warning ->
|
||||||
Text(
|
Text(
|
||||||
warning,
|
warning,
|
||||||
color = MaterialTheme.colorScheme.tertiary,
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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"
|
|
||||||
}
|
}
|
||||||
val interfaceStatus = when {
|
|
||||||
validation.interfaceReadable -> "Interface folder: found"
|
// WoW validation summary when valid
|
||||||
validation.hasInterface -> "Interface folder: found (not readable)"
|
uiState.wowValidation?.let { validation ->
|
||||||
else -> "Interface folder: not found"
|
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(
|
Text(
|
||||||
"$wtfStatus | $interfaceStatus",
|
"Theme changes apply immediately.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedButton(onClick = { viewModel.detectWoWLocation() }) {
|
VerticalScrollbar(
|
||||||
Text("Auto-detect")
|
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||||
}
|
adapter = rememberScrollbarAdapter(scrollState),
|
||||||
|
style = ScrollbarStyle(
|
||||||
// === Backup Settings ===
|
minimalHeight = 48.dp,
|
||||||
SectionHeader("Backup Settings")
|
thickness = 8.dp,
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
PathField(
|
hoverDurationMillis = 300,
|
||||||
label = "Backup destination",
|
unhoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
|
||||||
value = config.backupPath ?: "",
|
hoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
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(
|
// === Sticky Footer ===
|
||||||
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()
|
HorizontalDivider()
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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
|
// Force backup confirmation dialog
|
||||||
if (showForceBackupDialog) {
|
if (showForceBackupDialog) {
|
||||||
ConfirmationDialog(
|
ConfirmationDialog(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.rukira.wowbackup.ui.status
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -214,12 +215,33 @@ fun StatusScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Text(
|
Row(
|
||||||
"WoW Backup v${BuildConfig.VERSION}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"WoW Backup v${BuildConfig.VERSION}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
val update = uiState.updateInfo
|
||||||
|
if (update != null) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"• Update available (v${update.latestVersion})",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { DesktopActions.openUrl(update.releaseUrl) },
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
modifier = Modifier.height(26.dp),
|
||||||
|
) {
|
||||||
|
Text("Download", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,18 @@ import com.rukira.wowbackup.backup.BackupProgress
|
||||||
import com.rukira.wowbackup.backup.BackupResult
|
import com.rukira.wowbackup.backup.BackupResult
|
||||||
import com.rukira.wowbackup.backup.BackupScheduler
|
import com.rukira.wowbackup.backup.BackupScheduler
|
||||||
import com.rukira.wowbackup.config.ConfigManager
|
import com.rukira.wowbackup.config.ConfigManager
|
||||||
|
import com.rukira.wowbackup.update.UpdateChecker
|
||||||
|
import com.rukira.wowbackup.update.UpdateInfo
|
||||||
|
import com.rukira.wowbackup.update.UpdateService
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
@ -32,10 +37,25 @@ data class StatusUiState(
|
||||||
val backupProgress: BackupProgress? = null,
|
val backupProgress: BackupProgress? = null,
|
||||||
val totalBackups: Int = 0,
|
val totalBackups: Int = 0,
|
||||||
val backupPath: String? = null,
|
val backupPath: String? = null,
|
||||||
|
val updateInfo: UpdateInfo? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
@OptIn(kotlin.time.ExperimentalTime::class)
|
||||||
class StatusViewModel : ViewModel() {
|
class StatusViewModel(
|
||||||
|
private val updateService: UpdateService = UpdateChecker(),
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val updateInfo = MutableStateFlow<UpdateInfo?>(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
updateInfo.value = updateService.checkForUpdate()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Silently handle any uncaught exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only recomputes when backupPath or lastBackupResult changes — not on every progress tick
|
// Only recomputes when backupPath or lastBackupResult changes — not on every progress tick
|
||||||
private val totalBackups = combine(
|
private val totalBackups = combine(
|
||||||
|
|
@ -53,7 +73,8 @@ class StatusViewModel : ViewModel() {
|
||||||
ConfigManager.config,
|
ConfigManager.config,
|
||||||
BackupScheduler.state,
|
BackupScheduler.state,
|
||||||
totalBackups,
|
totalBackups,
|
||||||
) { config, scheduler, backupCount ->
|
updateInfo,
|
||||||
|
) { config, scheduler, backupCount, update ->
|
||||||
StatusUiState(
|
StatusUiState(
|
||||||
isConfigured = config.isConfigured,
|
isConfigured = config.isConfigured,
|
||||||
lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) },
|
lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) },
|
||||||
|
|
@ -66,6 +87,7 @@ class StatusViewModel : ViewModel() {
|
||||||
backupProgress = scheduler.currentProgress,
|
backupProgress = scheduler.currentProgress,
|
||||||
totalBackups = backupCount,
|
totalBackups = backupCount,
|
||||||
backupPath = config.backupPath,
|
backupPath = config.backupPath,
|
||||||
|
updateInfo = update,
|
||||||
)
|
)
|
||||||
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState())
|
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
### 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`.
|
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:**
|
**Files to modify:**
|
||||||
- `composeApp/build.gradle.kts` — add to desktop application config:
|
- `composeApp/build.gradle.kts` — add to desktop application config:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
application {
|
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
|
## Verification
|
||||||
1. `./gradlew composeApp:run` — app launches with tray icon visible
|
1. `./gradlew composeApp:run` — app launches with tray icon visible
|
||||||
2. Tray icon shows context menu with Status, Settings, Quit
|
2. Tray icon shows context menu with Status, Settings, Quit
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
**Status:** ✅ **Completed**
|
**Status:** ✅ **Completed**
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
Automated multi-platform packaging and release pipeline for WoW Backup using GitHub Actions. Whenever changes are merged/pushed to the `main` branch, the pipeline calculates the next release version and tag, compiles native distributables for macOS, Windows, and Linux, and attaches all packaged binaries to a published GitHub Release.
|
Automated multi-platform packaging and release pipeline for WoW Backup using GitHub Actions. Whenever changes are merged/pushed to the `main` branch, the pipeline calculates the next release version and tag, compiles native distributables for macOS, Windows, and Linux, and attaches all packaged binaries to a published GitHub Release as well as a published Forgejo Release on `git.asarius.site` (`rukira/wow-backup`).
|
||||||
|
|
||||||
## Deliverables
|
## Deliverables
|
||||||
1. **Dynamic Versioning in Gradle**:
|
1. **Dynamic Versioning in Gradle**:
|
||||||
|
|
@ -13,6 +13,7 @@ Automated multi-platform packaging and release pipeline for WoW Backup using Git
|
||||||
- Automated semantic patch version computation and git tagging on push to `main` and `workflow_dispatch`.
|
- Automated semantic patch version computation and git tagging on push to `main` and `workflow_dispatch`.
|
||||||
- Parallel runner matrix (`macos-latest`, `windows-latest`, `ubuntu-latest`) packaging `.dmg`, `.msi`, and `.deb`.
|
- Parallel runner matrix (`macos-latest`, `windows-latest`, `ubuntu-latest`) packaging `.dmg`, `.msi`, and `.deb`.
|
||||||
- Automated GitHub Release creation with attached artifacts and release notes.
|
- Automated GitHub Release creation with attached artifacts and release notes.
|
||||||
|
- Automated Forgejo Release creation on `git.asarius.site` (`rukira/wow-backup`) with attached distribution packages using `FORGEJO_PAT`.
|
||||||
3. **Installation Guide (`INSTALL.md`)**:
|
3. **Installation Guide (`INSTALL.md`)**:
|
||||||
- Detailed user installation guide for macOS (Gatekeeper bypass), Windows (SmartScreen bypass), and Linux (APT/DPKG).
|
- Detailed user installation guide for macOS (Gatekeeper bypass), Windows (SmartScreen bypass), and Linux (APT/DPKG).
|
||||||
- Linked directly from `README.md`.
|
- Linked directly from `README.md`.
|
||||||
|
|
|
||||||
|
|
@ -4,75 +4,13 @@ This document outlines the current state of the project, remaining core features
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Immediate Priority: Feature 5 (Restore System)
|
## Immediate Priority: Feature 5 (Restore System)
|
||||||
|
|
||||||
The core backup and scheduling pipeline is complete, but the restoration flow is currently a stub placeholder. Implementing Feature 5 is the final step to complete the original functional scope of `docs/masterplan.md`.
|
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
|
## Future Enhancements (Post-v1.0)
|
||||||
|
|
||||||
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**
|
1. **Multi-Flavour & Account Profile Support**
|
||||||
- Support backing up multiple game versions simultaneously (`_retail_`, `_classic_`, `_classic_era_`, `_ptr_`).
|
- 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: 246 KiB |
|
|
@ -14,6 +14,7 @@ materialKolor = "2.0.0"
|
||||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||||
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||||
|
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
|
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }
|
cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue