diff --git a/.gitea/issue_template/bug_report.yaml b/.gitea/issue_template/bug_report.yaml
index f8e95c4..bc436de 100644
--- a/.gitea/issue_template/bug_report.yaml
+++ b/.gitea/issue_template/bug_report.yaml
@@ -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.
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 9e78e71..df26346 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -70,7 +70,7 @@ 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)
@@ -82,6 +82,12 @@ compose.desktop {
macOS {
bundleID = "com.rukira.wowbackup"
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
+ infoPlist {
+ extraKeysRawXml = """
+ LSUIElement
+
+ """.trimIndent()
+ }
}
windows {
diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt
index a9e0d8b..aee168b 100644
--- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt
+++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt
@@ -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" }
diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt
index 63922c0..95dd10d 100644
--- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt
+++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt
@@ -63,214 +63,234 @@ fun ConfigScreen(
var showForceBackupDialog by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
- Box(modifier = Modifier.fillMaxSize()) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .verticalScroll(scrollState)
- .padding(24.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- Text("Settings", style = MaterialTheme.typography.headlineMedium)
+ Column(modifier = Modifier.fillMaxSize()) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text("Settings", style = MaterialTheme.typography.headlineMedium)
- // === WoW Installation ===
- SectionHeader("WoW Installation")
+ // === WoW Installation ===
+ SectionHeader("WoW Installation")
- PathField(
- label = "WoW install location",
- value = config.wowInstallPath ?: "",
- error = errors["wowPath"],
- onBrowse = {
- val dir = pickFolder("Select WoW Install Folder", config.wowInstallPath?.let { File(it) })
- if (dir != null) viewModel.updateWoWPath(dir.absolutePath)
- },
- )
+ PathField(
+ label = "WoW install location",
+ value = config.wowInstallPath ?: "",
+ error = errors["wowPath"],
+ onBrowse = {
+ val dir = pickFolder("Select WoW Install Folder", config.wowInstallPath?.let { File(it) })
+ if (dir != null) viewModel.updateWoWPath(dir.absolutePath)
+ },
+ )
- // WoW path warnings (e.g. missing WTF/Interface, permission issues)
- uiState.warnings["wowPath"]?.forEach { warning ->
- Text(
- warning,
- color = MaterialTheme.colorScheme.tertiary,
- style = MaterialTheme.typography.bodySmall,
- )
- }
-
- // WoW validation summary when valid
- uiState.wowValidation?.let { validation ->
- if (validation.isValid) {
- val wtfStatus = when {
- validation.wtfReadable -> "WTF folder: found"
- validation.hasWtf -> "WTF folder: found (not readable)"
- else -> "WTF folder: not found"
+ // WoW path warnings (e.g. missing WTF/Interface, permission issues)
+ uiState.warnings["wowPath"]?.forEach { warning ->
+ Text(
+ warning,
+ color = MaterialTheme.colorScheme.tertiary,
+ style = MaterialTheme.typography.bodySmall,
+ )
}
- val interfaceStatus = when {
- validation.interfaceReadable -> "Interface folder: found"
- validation.hasInterface -> "Interface folder: found (not readable)"
- else -> "Interface folder: not found"
+
+ // WoW validation summary when valid
+ uiState.wowValidation?.let { validation ->
+ if (validation.isValid) {
+ val wtfStatus = when {
+ validation.wtfReadable -> "WTF folder: found"
+ validation.hasWtf -> "WTF folder: found (not readable)"
+ else -> "WTF folder: not found"
+ }
+ val interfaceStatus = when {
+ validation.interfaceReadable -> "Interface folder: found"
+ validation.hasInterface -> "Interface folder: found (not readable)"
+ else -> "Interface folder: not found"
+ }
+ Text(
+ "$wtfStatus | $interfaceStatus",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
+
+ OutlinedButton(onClick = { viewModel.detectWoWLocation() }) {
+ Text("Auto-detect")
+ }
+
+ // === Backup Settings ===
+ SectionHeader("Backup Settings")
+
+ PathField(
+ label = "Backup destination",
+ value = config.backupPath ?: "",
+ error = errors["backupPath"],
+ onBrowse = {
+ val dir = pickFolder("Select Backup Destination", config.backupPath?.let { File(it) })
+ if (dir != null) viewModel.updateBackupPath(dir.absolutePath)
+ },
+ )
+
+ Text("Daily backup time", style = MaterialTheme.typography.bodyMedium)
+ TimePicker(
+ value = config.backupTimeOfDay,
+ onValueChange = { viewModel.updateBackupTime(it) },
+ modifier = Modifier.width(240.dp),
+ )
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Backups to keep:", style = MaterialTheme.typography.bodyMedium)
+ Spacer(Modifier.width(8.dp))
+ HistoryCountSelector(
+ value = config.backupHistoryCount,
+ onValueChange = { viewModel.updateBackupHistoryCount(it) },
+ )
+ }
+
+ CheckboxRow(
+ checked = config.forceBackupWhileRunning,
+ onCheckedChange = { checked ->
+ if (checked) {
+ showForceBackupDialog = true
+ } else {
+ viewModel.updateForceBackup(false)
+ }
+ },
+ label = "Allow backup while WoW is running",
+ )
+
+ // === Folders to Backup ===
+ SectionHeader("Folders to Backup")
+
+ if (errors.containsKey("folders")) {
+ Text(errors["folders"]!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
+ }
+
+ CheckboxRow(
+ checked = config.backupWtf,
+ onCheckedChange = { viewModel.updateBackupWtf(it) },
+ label = "WTF",
+ description = "Contains account settings, keybinds, macros, and addon saved variables.",
+ )
+
+ CheckboxRow(
+ checked = config.backupInterface,
+ onCheckedChange = { viewModel.updateBackupInterface(it) },
+ label = "Interface",
+ description = "Contains installed addons.",
+ )
+
+ // === Options ===
+ SectionHeader("Options")
+
+ CheckboxRow(
+ checked = config.compressionEnabled,
+ onCheckedChange = { viewModel.updateCompression(it) },
+ label = "Compression",
+ description = "Compress backups to save disk space. Slightly slower backup/restore.",
+ )
+
+ CheckboxRow(
+ checked = config.notificationsEnabled,
+ onCheckedChange = { viewModel.updateNotifications(it) },
+ label = "Notifications",
+ description = "Show system notifications for backup events.",
+ )
+
+ CheckboxRow(
+ checked = config.runAtStartup,
+ onCheckedChange = { viewModel.updateRunAtStartup(it) },
+ label = "Run at startup",
+ description = "Launch WoW Backup automatically when you log in.",
+ )
+
+ // === Appearance ===
+ SectionHeader("Appearance")
+
+ Text("Theme", style = MaterialTheme.typography.bodyMedium)
+ SingleChoiceSegmentedButtonRow {
+ ThemeMode.entries.forEachIndexed { index, mode ->
+ SegmentedButton(
+ selected = config.themeMode == mode,
+ onClick = { viewModel.updateThemeMode(mode) },
+ shape = SegmentedButtonDefaults.itemShape(index, ThemeMode.entries.size),
+ ) {
+ Text(
+ when (mode) {
+ ThemeMode.SYSTEM -> "System"
+ ThemeMode.LIGHT -> "Light"
+ ThemeMode.DARK -> "Dark"
+ },
+ )
+ }
+ }
+ }
+
+ Text("Accent color", style = MaterialTheme.typography.bodyMedium)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ AccentColor.entries.forEach { color ->
+ val isSelected = config.accentColor == color
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .background(color.seedColor, CircleShape)
+ .then(
+ if (isSelected) {
+ Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
+ } else {
+ Modifier
+ },
+ )
+ .clickable { viewModel.updateAccentColor(color) },
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isSelected) {
+ Text(
+ "\u2713",
+ color = MaterialTheme.colorScheme.surface,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ }
+ }
+ }
+
Text(
- "$wtfStatus | $interfaceStatus",
+ "Theme changes apply immediately.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
- }
- OutlinedButton(onClick = { viewModel.detectWoWLocation() }) {
- Text("Auto-detect")
- }
-
- // === Backup Settings ===
- SectionHeader("Backup Settings")
-
- PathField(
- label = "Backup destination",
- value = config.backupPath ?: "",
- error = errors["backupPath"],
- onBrowse = {
- val dir = pickFolder("Select Backup Destination", config.backupPath?.let { File(it) })
- if (dir != null) viewModel.updateBackupPath(dir.absolutePath)
- },
- )
-
- Text("Daily backup time", style = MaterialTheme.typography.bodyMedium)
- TimePicker(
- value = config.backupTimeOfDay,
- onValueChange = { viewModel.updateBackupTime(it) },
- modifier = Modifier.width(240.dp),
- )
-
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text("Backups to keep:", style = MaterialTheme.typography.bodyMedium)
- Spacer(Modifier.width(8.dp))
- HistoryCountSelector(
- value = config.backupHistoryCount,
- onValueChange = { viewModel.updateBackupHistoryCount(it) },
+ VerticalScrollbar(
+ modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
+ adapter = rememberScrollbarAdapter(scrollState),
+ style = ScrollbarStyle(
+ minimalHeight = 48.dp,
+ thickness = 8.dp,
+ shape = RoundedCornerShape(4.dp),
+ hoverDurationMillis = 300,
+ unhoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
+ hoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
+ ),
)
}
- CheckboxRow(
- checked = config.forceBackupWhileRunning,
- onCheckedChange = { checked ->
- if (checked) {
- showForceBackupDialog = true
- } else {
- viewModel.updateForceBackup(false)
- }
- },
- label = "Allow backup while WoW is running",
- )
-
- // === Folders to Backup ===
- SectionHeader("Folders to Backup")
-
- if (errors.containsKey("folders")) {
- Text(errors["folders"]!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
- }
-
- CheckboxRow(
- checked = config.backupWtf,
- onCheckedChange = { viewModel.updateBackupWtf(it) },
- label = "WTF",
- description = "Contains account settings, keybinds, macros, and addon saved variables.",
- )
-
- CheckboxRow(
- checked = config.backupInterface,
- onCheckedChange = { viewModel.updateBackupInterface(it) },
- label = "Interface",
- description = "Contains installed addons.",
- )
-
- // === Options ===
- SectionHeader("Options")
-
- CheckboxRow(
- checked = config.compressionEnabled,
- onCheckedChange = { viewModel.updateCompression(it) },
- label = "Compression",
- description = "Compress backups to save disk space. Slightly slower backup/restore.",
- )
-
- CheckboxRow(
- checked = config.notificationsEnabled,
- onCheckedChange = { viewModel.updateNotifications(it) },
- label = "Notifications",
- description = "Show system notifications for backup events.",
- )
-
- CheckboxRow(
- checked = config.runAtStartup,
- onCheckedChange = { viewModel.updateRunAtStartup(it) },
- label = "Run at startup",
- description = "Launch WoW Backup automatically when you log in.",
- )
-
- // === Appearance ===
- SectionHeader("Appearance")
-
- Text("Theme", style = MaterialTheme.typography.bodyMedium)
- SingleChoiceSegmentedButtonRow {
- ThemeMode.entries.forEachIndexed { index, mode ->
- SegmentedButton(
- selected = config.themeMode == mode,
- onClick = { viewModel.updateThemeMode(mode) },
- shape = SegmentedButtonDefaults.itemShape(index, ThemeMode.entries.size),
- ) {
- Text(
- when (mode) {
- ThemeMode.SYSTEM -> "System"
- ThemeMode.LIGHT -> "Light"
- ThemeMode.DARK -> "Dark"
- },
- )
- }
- }
- }
-
- Text("Accent color", style = MaterialTheme.typography.bodyMedium)
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- AccentColor.entries.forEach { color ->
- val isSelected = config.accentColor == color
- Box(
- modifier = Modifier
- .size(36.dp)
- .clip(CircleShape)
- .background(color.seedColor, CircleShape)
- .then(
- if (isSelected) {
- Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
- } else {
- Modifier
- },
- )
- .clickable { viewModel.updateAccentColor(color) },
- contentAlignment = Alignment.Center,
- ) {
- if (isSelected) {
- Text(
- "\u2713",
- color = MaterialTheme.colorScheme.surface,
- style = MaterialTheme.typography.labelLarge,
- )
- }
- }
- }
- }
-
- Text(
- "Theme changes apply immediately.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
-
- // === Footer ===
- Spacer(Modifier.height(8.dp))
+ // === Sticky Footer ===
HorizontalDivider()
- Spacer(Modifier.height(8.dp))
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -303,20 +323,6 @@ fun ConfigScreen(
}
}
- VerticalScrollbar(
- modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
- adapter = rememberScrollbarAdapter(scrollState),
- style = ScrollbarStyle(
- minimalHeight = 48.dp,
- thickness = 8.dp,
- shape = RoundedCornerShape(4.dp),
- hoverDurationMillis = 300,
- unhoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
- hoverColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
- ),
- )
- }
-
// Force backup confirmation dialog
if (showForceBackupDialog) {
ConfirmationDialog(
diff --git a/docs/plans/feature-1-system-tray.md b/docs/plans/feature-1-system-tray.md
index 699456f..826321a 100644
--- a/docs/plans/feature-1-system-tray.md
+++ b/docs/plans/feature-1-system-tray.md
@@ -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 = """
+ LSUIElement
+
+ """.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
diff --git a/docs/roadmap.md b/docs/roadmap.md
index bcbbaec..5afa40c 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.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`.
-### 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_`).