Compare commits

...

19 commits

Author SHA1 Message Date
Rukira
b0aaeb940b Hero image 2026-08-25 12:21:48 +01:00
Rukira
2c7c530e22 Hero image 2026-08-25 12:20:21 +01:00
Rukira
c8d85912b8 Updating INSTALL.md 2026-08-25 12:04:54 +01:00
Rukira
cf9840c1b6 Release template fix 2026-08-25 12:02:34 +01:00
Rukira
ed1c00ad11 Fixing install link 2026-08-25 11:48:58 +01:00
Rukira
482089cd62 Hiding dock icon for MacOS 2026-08-25 11:38:37 +01:00
Rukira
cdda6c9156 Sticky buttons in Settings 2026-08-25 11:32:19 +01:00
Rukira
6fe133766e Roadmap update 2026-08-25 11:24:05 +01:00
Rukira
9cb66f888d Bug report template 2026-08-25 10:44:52 +01:00
Rukira
613ceb01b7 feat(update): add update detection and notification on status screen (WB-008)
Co-authored-by: Junie <junie@jetbrains.com>
2026-08-25 10:30:52 +01:00
Rukira
02cbf20946 Fixing versioning 2026-08-25 10:21:39 +01:00
Rukira
6e15676f34 Cleaning up install instructions 2026-08-25 10:19:13 +01:00
Rukira
46587f7d28 Fixing versioning 2026-08-25 10:17:38 +01:00
Rukira
df0640f857 Unlicense 2026-08-25 10:10:15 +01:00
Rukira
d211b34405 License and readme updates 2026-08-25 10:09:41 +01:00
Rukira
616fd21872 Fixing MacOS build issue. Improving documentation. 2026-08-25 09:40:28 +01:00
Rukira
cfd093af72 Fixing release job 2026-08-24 20:05:00 +01:00
Rukira
51bc86ec6d feat(release): add Forgejo release step to GitHub Actions pipeline
Co-authored-by: Junie <junie@jetbrains.com>
2026-08-24 16:56:00 +01:00
Rukira
c73f210b83 Merge pull request #1 from GoncaloJoaoCorreia/feat/WB-006-release-pipeline
Release pipeline
2026-08-24 16:25:42 +01:00
24 changed files with 996 additions and 362 deletions

View file

@ -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.

View file

@ -99,7 +99,7 @@ jobs:
if-no-files-found: error
create-release:
name: Create GitHub Release
name: Create GitHub & Forgejo Releases
needs:
- bump-version
- package
@ -140,3 +140,69 @@ jobs:
gh release create "${RELEASE_TAG}" dist/* \
--title "WoW Backup ${RELEASE_TAG}" \
--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."

View file

@ -1,22 +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` |
| **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.
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"*).
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"
```
1. Open **System Settings**
2. Navigate to **Security & Privacy**.
3. Click **Open Anyway** under **Allow apps downloaded from**.
4. Restart WoW Backup.
---
## 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 +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.
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.
---
## 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
View 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>

View file

@ -1,22 +1,28 @@
# WoW Backup
![WoW Backup](docs/screenshots/hero.png)
A lightweight, native-feeling desktop application built with Kotlin and Compose Multiplatform to automatically backup and restore World of Warcraft configuration data (WTF and Interface directories) on macOS and Windows.
## Installation & Download
## Overview
Pre-built binaries for **macOS**, **Windows**, and **Linux** are provided on each release.
World of Warcraft stores all UI configurations, macros, keybindings, and addon saved variables in the `WTF/` and `Interface/` folders. Losing this data due to game corruption, accidental deletion, or addon errors can mean losing years of customized setups. **WoW Backup** runs discreetly in the system tray, automatically creates scheduled, timestamped backups (optionally compressed as ZIP archives), and will provide seamless one-click restoration.
👉 **See [here](INSTALL.md)** for download links and step-by-step installation instructions.
## Key Features
- 🛡️ **System Tray Native Life Cycle**: Lives in the menu bar / system tray with custom template icons matching light/dark OS appearances. Closing the window hides to tray; quitting is explicit.
- ⚙️ **Smart Auto-Configuration**: Automatically detects standard WoW install locations (supporting retail and classic directories) and validates folder permissions.
- ⏰ **Automated & Scheduled Backups**: Background scheduler powered by Cardiologist ensures dependable daily backups at a user-defined time.
- 📦 **Compression & History Management**: Supports both folder replication and ZIP compression, with automatic pruning of backups beyond the configured retention limit (130 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 [LICENSE](LICENSE) file or [unlicense.org](https://unlicense.org/) for details.

View file

@ -31,16 +31,21 @@ 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 outputDir = generatedSrcDir
val version = appVersion
inputs.property("version", version)
outputs.dir(outputDir)
doLast {
val dir = outputDir.get().asFile.resolve("com/rukira/wowbackup")
@ -65,16 +70,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")
macOS {
bundleID = "com.rukira.wowbackup"
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
infoPlist {
extraKeysRawXml = """
<key>LSUIElement</key>
<true/>
""".trimIndent()
}
}
windows {

View file

@ -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)
}
}
}

View file

@ -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" }

View file

@ -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" }
}
}
}

View file

@ -63,7 +63,12 @@ fun ConfigScreen(
var showForceBackupDialog by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxSize()
@ -263,14 +268,29 @@ fun ConfigScreen(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// === Footer ===
Spacer(Modifier.height(8.dp))
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),
),
)
}
// === 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(

View file

@ -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))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"WoW Backup v${BuildConfig.VERSION}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
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)
}
}
}
}
}

View file

@ -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())

View file

@ -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)
}
}
}

View file

@ -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"
}
}

View file

@ -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")
}
}

View file

@ -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'")
}
}

View file

@ -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)
}
}

View file

@ -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())
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -3,7 +3,7 @@
**Status:** ✅ **Completed**
## 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
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`.
- 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 Forgejo Release creation on `git.asarius.site` (`rukira/wow-backup`) with attached distribution packages using `FORGEJO_PAT`.
3. **Installation Guide (`INSTALL.md`)**:
- Detailed user installation guide for macOS (Gatekeeper bypass), Windows (SmartScreen bypass), and Linux (APT/DPKG).
- Linked directly from `README.md`.

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

View file

@ -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-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }