diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt index 8432651..160eddc 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/main.kt @@ -19,6 +19,7 @@ import com.rukira.wowbackup.config.ConfigManager import com.rukira.wowbackup.logging.LoggingSetup import com.rukira.wowbackup.platform.OS import com.rukira.wowbackup.platform.Platform +import com.rukira.wowbackup.platform.StartupManager import com.rukira.wowbackup.platform.WoWLocations import com.rukira.wowbackup.ui.Screen import com.rukira.wowbackup.ui.trayIconPainter @@ -34,6 +35,9 @@ fun main() { ConfigManager.load() logger.info { "Config loaded. Configured: ${ConfigManager.isConfigured}" } + // Sync OS startup registration with persisted config + StartupManager.setEnabled(ConfigManager.config.value.runAtStartup) + val detectedWoW = WoWLocations.findWoWInstall() if (detectedWoW != null) { logger.info { "Auto-detected WoW at: $detectedWoW" } @@ -67,10 +71,6 @@ fun main() { isWindowVisible = true }) Separator() - Item("Backup Now", onClick = { - BackupScheduler.triggerBackupNow() - }) - Separator() Item("Quit", onClick = { BackupScheduler.stop() exitApplication() diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/StartupManager.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/StartupManager.kt new file mode 100644 index 0000000..8cb15f7 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/StartupManager.kt @@ -0,0 +1,127 @@ +package com.rukira.wowbackup.platform + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.File + +private val logger = KotlinLogging.logger {} + +object StartupManager { + + private const val APP_ID = "com.rukira.wowbackup" + + fun setEnabled(enabled: Boolean) { + val appPath = getAppPath() + if (appPath == null) { + logger.warn { "Cannot register startup: not running from a packaged app (dev mode?)" } + return + } + + logger.info { "Setting launch-at-startup to $enabled (appPath=$appPath)" } + + when (Platform.current) { + OS.Mac -> if (enabled) enableMac(appPath) else disableMac() + OS.Windows -> if (enabled) enableWindows(appPath) else disableWindows() + else -> logger.warn { "Launch at startup is not supported on ${Platform.current}" } + } + } + + private fun getAppPath(): String? = when (Platform.current) { + OS.Mac -> { + // java.home is e.g. /Applications/com.rukira.wowbackup.app/Contents/runtime/Contents/Home + val javaHome = System.getProperty("java.home") ?: return null + val marker = ".app/Contents" + val idx = javaHome.indexOf(marker) + if (idx < 0) null else javaHome.substring(0, idx + ".app".length) + } + OS.Windows -> { + ProcessHandle.current().info().command().orElse(null)?.takeIf { it.endsWith(".exe", ignoreCase = true) } + } + else -> null + } + + // --- macOS: LaunchAgent plist --- + + private val plistFile: File + get() = File(System.getProperty("user.home"), "Library/LaunchAgents/$APP_ID.plist") + + private fun enableMac(appPath: String) { + val plist = """ + | + | + | + | + | Label + | $APP_ID + | ProgramArguments + | + | open + | -a + | $appPath + | + | RunAtLoad + | + | + | + """.trimMargin() + + try { + plistFile.parentFile.mkdirs() + plistFile.writeText(plist) + logger.info { "LaunchAgent plist written to ${plistFile.absolutePath}" } + } catch (e: Exception) { + logger.error(e) { "Failed to write LaunchAgent plist" } + } + } + + private fun disableMac() { + try { + if (plistFile.exists()) { + plistFile.delete() + logger.info { "LaunchAgent plist removed" } + } + } catch (e: Exception) { + logger.error(e) { "Failed to remove LaunchAgent plist" } + } + } + + // --- Windows: Registry --- + + private const val REG_KEY = """HKCU\Software\Microsoft\Windows\CurrentVersion\Run""" + private const val REG_VALUE = "WoWBackup" + + private fun enableWindows(appPath: String) { + try { + val process = ProcessBuilder( + "reg", "add", REG_KEY, "/v", REG_VALUE, "/d", appPath, "/f" + ).redirectErrorStream(true).start() + val exitCode = process.waitFor() + if (exitCode == 0) { + logger.info { "Registry startup entry added" } + } else { + val output = process.inputStream.bufferedReader().readText() + logger.error { "reg add failed (exit $exitCode): $output" } + } + } catch (e: Exception) { + logger.error(e) { "Failed to add registry startup entry" } + } + } + + private fun disableWindows() { + try { + val process = ProcessBuilder( + "reg", "delete", REG_KEY, "/v", REG_VALUE, "/f" + ).redirectErrorStream(true).start() + val exitCode = process.waitFor() + if (exitCode == 0) { + logger.info { "Registry startup entry removed" } + } else { + val output = process.inputStream.bufferedReader().readText() + // Exit code 1 with "not found" is expected when disabling and key doesn't exist + logger.warn { "reg delete exited $exitCode: $output" } + } + } catch (e: Exception) { + logger.error(e) { "Failed to remove registry startup entry" } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt index c2fb730..d78b938 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigViewModel.kt @@ -5,6 +5,7 @@ import com.rukira.wowbackup.config.AccentColor import com.rukira.wowbackup.config.AppConfig import com.rukira.wowbackup.config.ConfigManager import com.rukira.wowbackup.config.ThemeMode +import com.rukira.wowbackup.platform.StartupManager import com.rukira.wowbackup.platform.WoWLocations import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.flow.MutableStateFlow @@ -170,6 +171,7 @@ class ConfigViewModel : ViewModel() { } ConfigManager.save(_state.value.config) + StartupManager.setEnabled(_state.value.config.runAtStartup) _state.update { it.copy(errors = emptyMap(), saveSuccess = true) } logger.info { "Configuration saved" } return true