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/.github/workflows/release.yml b/.github/workflows/release.yml index 5076c0e..b2360d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,7 +154,7 @@ jobs: #### Available Packages - **macOS**: \`.dmg\` installer (Apple Silicon & Intel) - **Windows**: \`.msi\` Windows Installer - - **Linux**: \`.deb\` package (Debian / Ubuntu) + - **Linux (Experimental)**: \`.deb\` package (Debian / Ubuntu) --- For detailed setup and installation notes, please refer to [INSTALL.md](https://git.asarius.site/rukira/wow-backup/src/tag/${RELEASE_TAG}/INSTALL.md)." @@ -206,3 +206,33 @@ jobs: done echo "Forgejo release published successfully." + + - name: Update Homebrew Tap Cask + env: + FORGEJO_PAT: ${{ secrets.FORGEJO_PAT }} + RELEASE_VERSION: ${{ needs.bump-version.outputs.version }} + run: | + DMG_FILE=$(find dist -name "*.dmg" | head -n 1) + if [ -z "$DMG_FILE" ] || [ ! -f "$DMG_FILE" ]; then + echo "::error::No DMG file found in dist/ to update Homebrew Cask." + exit 1 + fi + + SHA256=$(sha256sum "$DMG_FILE" | awk '{print $1}') + echo "Calculated DMG SHA256: ${SHA256} for version ${RELEASE_VERSION}" + + echo "Cloning homebrew-tap repository..." + git clone "https://rukira:${FORGEJO_PAT}@git.asarius.site/rukira/homebrew-tap.git" tap-repo + cd tap-repo + + echo "Updating Casks/wow-backup.rb..." + sed -i "s/version \".*\"/version \"${RELEASE_VERSION}\"/" Casks/wow-backup.rb + sed -i "s/sha256 \".*\"/sha256 \"${SHA256}\"/" Casks/wow-backup.rb + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Casks/wow-backup.rb + git commit -m "chore(cask): update wow-backup to v${RELEASE_VERSION}" || echo "No changes to commit" + git push origin main + + echo "Homebrew tap updated successfully." diff --git a/INSTALL.md b/INSTALL.md index 6d6198f..1e39ff3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,7 +2,7 @@ ## 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 | |--------------------------|---|---| @@ -10,23 +10,7 @@ Download the appropriate installer for your platform from the **[latest release] | **Windows** | Windows Installer (MSI) | `WoW Backup-.msi` | | **Linux** (Experimental) | Debian Package (DEB) | `wow-backup__amd64.deb` | ---- - -## 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. +_Note: The Linux version is untested and experimental. Use at your own risk._ --- @@ -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) 1. Download the `.deb` package from the latest release. diff --git a/README.md b/README.md index b533624..5fd72ff 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,4 @@ -# WoW Backup - -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). +![WoW Backup](docs/screenshots/hero.png) ## Installation & Download @@ -75,4 +71,4 @@ The app uses a configuration file to store user preferences. This file and runti ## 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. diff --git a/LICENSE b/UNLICENSE similarity index 100% rename from LICENSE rename to UNLICENSE diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9e78e71..c7891ab 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -12,12 +12,12 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - implementation(compose.ui) - implementation(compose.components.resources) - implementation(compose.components.uiToolingPreview) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.ui) + implementation(libs.compose.components.resources) + implementation(libs.compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) } @@ -42,7 +42,8 @@ val appVersion = providers.gradleProperty("appVersion").orNull?.takeIf { it.isNo val generatedSrcDir = layout.buildDirectory.dir("generated/src/jvmMain/kotlin") -val generateBuildConfig by tasks.registering { +val generateBuildConfig = tasks.register("generateBuildConfig") { + description = "Generates BuildConfig.kt" val outputDir = generatedSrcDir val version = appVersion inputs.property("version", version) @@ -70,18 +71,24 @@ compose.desktop { application { mainClass = "com.rukira.wowbackup.MainKt" - jvmArgs("-Dapple.awt.enableTemplateImages=true") + jvmArgs("-Dapple.awt.enableTemplateImages=true", "-Dapple.awt.UIElement=true") nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "WoW Backup" packageVersion = appVersion - modules("java.naming", "java.sql", "java.instrument", "jdk.unsupported") + modules("java.naming", "java.sql", "java.instrument", "jdk.unsupported", "java.net.http") macOS { bundleID = "com.rukira.wowbackup" iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + infoPlist { + extraKeysRawXml = """ + 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/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt index 9b4c7e7..0f5fd4d 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/theme/Theme.kt @@ -20,7 +20,7 @@ fun WoWBackupTheme( DynamicMaterialTheme( seedColor = accentColor.seedColor, - useDarkTheme = useDarkTheme, + isDark = useDarkTheme, content = content, ) } 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_`). diff --git a/docs/screenshots/hero.png b/docs/screenshots/hero.png new file mode 100644 index 0000000..be71afb Binary files /dev/null and b/docs/screenshots/hero.png differ diff --git a/docs/screenshots/wow-backup-hero.xcf b/docs/screenshots/wow-backup-hero.xcf new file mode 100644 index 0000000..acca288 Binary files /dev/null and b/docs/screenshots/wow-backup-hero.xcf differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8a2a2a..b5a27d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,25 @@ [versions] -androidx-lifecycle = "2.10.0-alpha08" -composeMultiplatform = "1.11.0-alpha03" +androidx-lifecycle = "2.11.0" +composeMaterial3 = "1.9.0" +composeMultiplatform = "1.11.1" kotlin = "2.2.21" -kotlinx-coroutines = "1.10.2" -kotlinx-datetime = "0.6.2" -kotlinx-serialization = "1.10.0" +kotlinx-coroutines = "1.11.0" +kotlinx-datetime = "0.8.0" +kotlinx-serialization = "1.11.0" cardiologist = "0.8.0" -kotlin-logging = "7.0.3" -logback = "1.5.18" -materialKolor = "2.0.0" +kotlin-logging = "8.0.4" +logback = "1.5.38" +materialKolor = "4.0.5" [libraries] androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } +compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } +compose-components-uiToolingPreview = { module = "org.jetbrains.compose.components:components-ui-tooling-preview", version.ref = "composeMultiplatform" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 19a6bde..c42672d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME