Compare commits

..

31 commits
v1.0.3 ... main

Author SHA1 Message Date
Rukira
d4fb6a25be Fixing Compose dependencies 2026-08-27 10:43:40 +01:00
Rukira
2eeebee36d Fixing deprecated gradle task declaration 2026-08-27 10:34:47 +01:00
Rukira
616a7a9a95 chore: bump kotlinx-datetime to 0.8.0 (batch 5)
Co-authored-by: Junie <junie@jetbrains.com>
2026-08-27 10:22:28 +01:00
Rukira
d0887d88ba chore: bump materialKolor to 4.0.5 and update Theme parameter (batch 4)
Co-authored-by: Junie <junie@jetbrains.com>
2026-08-27 10:21:27 +01:00
Rukira
ae845d422c chore: bump kotlin-logging to 8.0.4 (batch 3)
Co-authored-by: Junie <junie@jetbrains.com>
2026-08-27 10:19:46 +01:00
Rukira
5c1b8b3cc8 chore: bump composeMultiplatform and androidx-lifecycle to stable (batch 2)
Co-authored-by: Junie <junie@jetbrains.com>
2026-08-27 10:19:24 +01:00
Rukira
3e2fcf87a8 chore: bump low-risk dependencies and gradle wrapper (batch 1)
Co-authored-by: Junie <junie@jetbrains.com>
2026-08-27 10:18:43 +01:00
Rukira
03b90e2afe Brew install 2026-08-27 09:50:26 +01:00
Rukira
58f493e561 Hero banner 2026-08-27 09:13:23 +01:00
Rukira
629ae6899a Publish via Homebrew 2026-08-25 16:06:46 +01:00
Rukira
f6d754ff0a Fixing missing http package 2026-08-25 15:33:34 +01:00
Rukira
2ccdb02ba7 Unlicense 2026-08-25 12:47:44 +01:00
Rukira
0784d7ce3f Hero image 2026-08-25 12:21:48 +01:00
Rukira
9a9b42a88c Hero image 2026-08-25 12:20:21 +01:00
Rukira
8bc24446fa Updating INSTALL.md 2026-08-25 12:04:54 +01:00
Rukira
fc4451bfbc Release template fix 2026-08-25 12:02:34 +01:00
Rukira
6e5fd227bf Fixing install link 2026-08-25 11:48:58 +01:00
Rukira
e873f14a7b Hiding dock icon for MacOS 2026-08-25 11:38:37 +01:00
Rukira
b7bff1a7bd Sticky buttons in Settings 2026-08-25 11:32:19 +01:00
Rukira
7056758728 Roadmap update 2026-08-25 11:24:05 +01:00
Rukira
65962f3581 Bug report template 2026-08-25 10:44:52 +01:00
Rukira
472654180a 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
3a7a92800b Fixing versioning 2026-08-25 10:21:39 +01:00
Rukira
892448c57f Cleaning up install instructions 2026-08-25 10:19:13 +01:00
Rukira
5be2247cb4 Fixing versioning 2026-08-25 10:17:38 +01:00
Rukira
5b4798b7b2 Unlicense 2026-08-25 10:10:15 +01:00
Rukira
88e64b25c8 License and readme updates 2026-08-25 10:09:41 +01:00
Rukira
0ea2067e42 Fixing MacOS build issue. Improving documentation. 2026-08-25 09:40:28 +01:00
Rukira
033febece6 Fixing release job 2026-08-24 20:05:00 +01:00
Rukira
12e6a6f94b 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
31a3cbc327 Merge pull request #1 from feat/WB-006-release-pipeline
Release pipeline
2026-08-24 16:25:42 +01:00
15 changed files with 353 additions and 315 deletions

View file

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

View file

@ -154,7 +154,7 @@ jobs:
#### Available Packages #### Available Packages
- **macOS**: \`.dmg\` installer (Apple Silicon & Intel) - **macOS**: \`.dmg\` installer (Apple Silicon & Intel)
- **Windows**: \`.msi\` Windows Installer - **Windows**: \`.msi\` Windows Installer
- **Linux**: \`.deb\` package (Debian / Ubuntu) - **Linux (Experimental)**: \`.deb\` package (Debian / Ubuntu)
--- ---
For detailed setup and installation notes, please refer to [INSTALL.md](https://git.asarius.site/rukira/wow-backup/src/tag/${RELEASE_TAG}/INSTALL.md)." For detailed setup and installation notes, please refer to [INSTALL.md](https://git.asarius.site/rukira/wow-backup/src/tag/${RELEASE_TAG}/INSTALL.md)."
@ -206,3 +206,33 @@ jobs:
done done
echo "Forgejo release published successfully." echo "Forgejo release published successfully."
- name: Update Homebrew Tap Cask
env:
FORGEJO_PAT: ${{ secrets.FORGEJO_PAT }}
RELEASE_VERSION: ${{ needs.bump-version.outputs.version }}
run: |
DMG_FILE=$(find dist -name "*.dmg" | head -n 1)
if [ -z "$DMG_FILE" ] || [ ! -f "$DMG_FILE" ]; then
echo "::error::No DMG file found in dist/ to update Homebrew Cask."
exit 1
fi
SHA256=$(sha256sum "$DMG_FILE" | awk '{print $1}')
echo "Calculated DMG SHA256: ${SHA256} for version ${RELEASE_VERSION}"
echo "Cloning homebrew-tap repository..."
git clone "https://rukira:${FORGEJO_PAT}@git.asarius.site/rukira/homebrew-tap.git" tap-repo
cd tap-repo
echo "Updating Casks/wow-backup.rb..."
sed -i "s/version \".*\"/version \"${RELEASE_VERSION}\"/" Casks/wow-backup.rb
sed -i "s/sha256 \".*\"/sha256 \"${SHA256}\"/" Casks/wow-backup.rb
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/wow-backup.rb
git commit -m "chore(cask): update wow-backup to v${RELEASE_VERSION}" || echo "No changes to commit"
git push origin main
echo "Homebrew tap updated successfully."

View file

@ -2,7 +2,7 @@
## Download ## Download
Download the appropriate installer for your platform from the **[latest release](https://git.asarius.site/rukira/wow-backup/releases)**: 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 |
|--------------------------|---|---| |--------------------------|---|---|
@ -10,23 +10,7 @@ Download the appropriate installer for your platform from the **[latest release]
| **Windows** | Windows Installer (MSI) | `WoW Backup-<version>.msi` | | **Windows** | Windows Installer (MSI) | `WoW Backup-<version>.msi` |
| **Linux** (Experimental) | 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
1. Download the `.dmg` file from the latest release.
2. Double-click the `.dmg` file to mount the disk image.
3. Drag **WoW Backup.app** into your `/Applications` folder.
4. Eject the disk image.
### Unsigned Application / Gatekeeper Notice
Because WoW Backup is distributed without Apple Developer ID code-signing notarization, macOS Gatekeeper may show a warning on first launch (*"WoW Backup cannot be opened because the developer cannot be verified"* or *"Apple could not verify that WoW Backup is free of malware"*).
To launch the app for the first time:
1. Open **System Settings**
2. Navigate to **Security & Privacy**.
3. Click **Open Anyway** under **Allow apps downloaded from**.
4. Restart WoW Backup.
--- ---
@ -44,6 +28,58 @@ If Windows Defender SmartScreen flags the installer (*"Windows protected your PC
--- ---
## macOS
### Option 1: Manual DMG Download
1. Download the `.dmg` file from the **[latest release](https://git.asarius.site/rukira/wow-backup/releases/latest)**.
2. Double-click the `.dmg` file to mount the disk image.
3. Drag **WoW Backup.app** into your `/Applications` folder.
4. Eject the disk image.
#### Unsigned Application / Gatekeeper Notice
Because WoW Backup is distributed without Apple Developer ID code-signing notarization, macOS Gatekeeper may show a warning on first launch (*"WoW Backup cannot be opened because the developer cannot be verified"* or *"Apple could not verify that WoW Backup is free of malware"*).
To launch the app:
- **Terminal (Fastest):** Run the following command to strip the quarantine attribute:
```shell
xattr -cr "/Applications/WoW Backup.app"
```
- **macOS System Settings:**
1. Open **System Settings****Privacy & Security**.
2. Scroll down to the **Security** section.
3. Click **Open Anyway** next to the WoW Backup notice.
4. Confirm and launch the app.
---
### Option 2: Install via [Homebrew](https://brew.sh/)
**Install:**
1. Add the tap:
```shell
brew tap rukira/tap https://git.asarius.site/rukira/homebrew-tap.git
```
2. Install WoW Backup:
```shell
brew install --cask wow-backup
```
**Update:**
```shell
brew update
brew upgrade --cask wow-backup
```
**Uninstall:**
```shell
brew uninstall --cask --zap wow-backup
```
---
## Linux (Experimental) ## Linux (Experimental)
1. Download the `.deb` package from the latest release. 1. Download the `.deb` package from the latest release.

View file

@ -1,8 +1,4 @@
# WoW Backup ![WoW Backup](docs/screenshots/hero.png)
A lightweight, native-feeling desktop application to automatically backup World of Warcraft configuration data (WTF and Interface directories).
World of Warcraft stores all UI configurations, macros, keybindings, and addon data 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, and automatically performs scheduled, timestamped backups (optionally compressed as ZIP archives).
## Installation & Download ## Installation & Download
@ -75,4 +71,4 @@ The app uses a configuration file to store user preferences. This file and runti
## License ## License
This project is released into the public domain. See the [LICENSE](LICENSE) file or [unlicense.org](https://unlicense.org/) for details. This project is released into the public domain. See the [UNLICENSE](UNLICENSE) file or [unlicense.org](https://unlicense.org/) for details.

View file

View file

@ -12,12 +12,12 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation(compose.runtime) implementation(libs.compose.runtime)
implementation(compose.foundation) implementation(libs.compose.foundation)
implementation(compose.material3) implementation(libs.compose.material3)
implementation(compose.ui) implementation(libs.compose.ui)
implementation(compose.components.resources) implementation(libs.compose.components.resources)
implementation(compose.components.uiToolingPreview) implementation(libs.compose.components.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
} }
@ -42,7 +42,8 @@ val appVersion = providers.gradleProperty("appVersion").orNull?.takeIf { it.isNo
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 = tasks.register("generateBuildConfig") {
description = "Generates BuildConfig.kt"
val outputDir = generatedSrcDir val outputDir = generatedSrcDir
val version = appVersion val version = appVersion
inputs.property("version", version) inputs.property("version", version)
@ -70,18 +71,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") modules("java.naming", "java.sql", "java.instrument", "jdk.unsupported", "java.net.http")
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 {

View file

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

View file

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

View file

@ -20,7 +20,7 @@ fun WoWBackupTheme(
DynamicMaterialTheme( DynamicMaterialTheme(
seedColor = accentColor.seedColor, seedColor = accentColor.seedColor,
useDarkTheme = useDarkTheme, isDark = useDarkTheme,
content = content, content = content,
) )
} }

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

View file

@ -1,18 +1,25 @@
[versions] [versions]
androidx-lifecycle = "2.10.0-alpha08" androidx-lifecycle = "2.11.0"
composeMultiplatform = "1.11.0-alpha03" composeMaterial3 = "1.9.0"
composeMultiplatform = "1.11.1"
kotlin = "2.2.21" kotlin = "2.2.21"
kotlinx-coroutines = "1.10.2" kotlinx-coroutines = "1.11.0"
kotlinx-datetime = "0.6.2" kotlinx-datetime = "0.8.0"
kotlinx-serialization = "1.10.0" kotlinx-serialization = "1.11.0"
cardiologist = "0.8.0" cardiologist = "0.8.0"
kotlin-logging = "7.0.3" kotlin-logging = "8.0.4"
logback = "1.5.18" logback = "1.5.38"
materialKolor = "2.0.0" materialKolor = "4.0.5"
[libraries] [libraries]
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" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-components-uiToolingPreview = { module = "org.jetbrains.compose.components:components-ui-tooling-preview", version.ref = "composeMultiplatform" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-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-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" }

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME