wow-backup/docs/plans/feature-1-system-tray.md
2026-03-04 14:19:19 +00:00

3.6 KiB

Feature 1: System Tray Icon

Context

The app lives primarily in the system tray. The window is secondary — it opens for configuration/status and closes back to tray. This is the shell that all UI features plug into.

Dependencies

  • Depends on: Feature 0 (Foundation) — logging, platform detection
  • Depended on by: Features 2-5 (all UI features are accessed through the tray)

Approach

Use Compose Desktop's built-in Tray composable (androidx.compose.ui.window.Tray). No extra libraries needed — it supports icons, tooltips, context menus, and notifications natively.

App Lifecycle Model

The app always runs with a tray icon visible. The main window is shown/hidden based on user interaction. Closing the window hides it to tray — it does not exit the app. Exiting is only via the tray menu "Quit" item.

Implementation Steps

Step 1: Create a tray icon asset

Files to create:

  • composeApp/src/jvmMain/resources/tray-icon.png (32x32, simple recognizable icon)

For macOS dark mode support, add JVM arg: -Dapple.awt.enableTemplateImages=true

Step 2: Restructure main.kt for tray-first lifecycle

Files to modify:

  • composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt
fun main() = application {
    // Initialize logging (from Feature 0)

    var isWindowVisible by remember { mutableStateOf(true) }
    // Which screen to show: STATUS (default) or CONFIG
    var currentScreen by remember { mutableStateOf(Screen.STATUS) }

    Tray(
        icon = painterResource("tray-icon.png"),
        tooltip = "WoW Backup",
        onAction = { isWindowVisible = true }, // click tray -> show window
        menu = {
            Item("Status", onClick = {
                currentScreen = Screen.STATUS
                isWindowVisible = true
            })
            Item("Settings", onClick = {
                currentScreen = Screen.CONFIG
                isWindowVisible = true
            })
            Separator()
            Item("Quit", onClick = ::exitApplication)
        }
    )

    if (isWindowVisible) {
        Window(
            onCloseRequest = { isWindowVisible = false }, // close -> hide to tray
            title = "WoW Backup",
        ) {
            App(currentScreen)
        }
    }
}

Step 3: Define screen navigation enum

Files to create:

  • composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/Screen.kt
enum class Screen { STATUS, CONFIG, RESTORE }

Step 4: Update App.kt for screen routing

Files to modify:

  • composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt

Strip all template code. Route to placeholder screens based on currentScreen:

  • Screen.STATUS -> "Status screen placeholder"
  • Screen.CONFIG -> "Config screen placeholder"
  • Screen.RESTORE -> "Restore screen placeholder"

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

Files to modify:

  • composeApp/build.gradle.kts — add to desktop application config:
compose.desktop {
    application {
        jvmArgs("-Dapple.awt.enableTemplateImages=true")
    }
}

Verification

  1. ./gradlew composeApp:run — app launches with tray icon visible
  2. Tray icon shows context menu with Status, Settings, Quit
  3. Clicking "Status" or "Settings" opens the window to the correct placeholder
  4. Closing the window hides it (app stays running in tray)
  5. Clicking tray icon again re-shows the window
  6. "Quit" exits the app fully
  7. On first run (no config), window auto-opens to config screen