diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3a03b45..49936ae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,7 +34,11 @@ dependencies { compileOnly(libs.log4j.core) runtimeOnly(libs.log4j.slf4j2.impl) + implementation(libs.jna) + implementation(libs.jna.platform) + implementation(libs.compose.runtime) + implementation(libs.compose.native.tray) val onlyWindowsX64: Boolean by rootProject.extra val onlyWindowsArm64: Boolean by rootProject.extra @@ -181,6 +185,18 @@ val copyrightYears = if (now > it) "$it–$now" else "$it" } +val optimizedJvmArgs = + listOf( + "-Xms128m", + "-Xmx2g", + "-XX:+UseG1GC", + "-XX:MaxGCPauseMillis=50", + "-XX:InitiatingHeapOccupancyPercent=30", + "-XX:G1ReservePercent=15", + "-XX:+ParallelRefProcEnabled", + "-XX:+UseStringDeduplication", + ) + compose.desktop.application { mainClass = mainFunction javaHome = sequenceOf( @@ -207,6 +223,8 @@ compose.desktop.application { """.trimIndent(), ) + jvmArgs += optimizedJvmArgs + nativeDistributions { packageName = "ServerListExplorer" @@ -244,6 +262,7 @@ compose.desktop.application { obfuscate.set(false) configurationFiles.from( rootProject.file("proguard/base.pro"), + rootProject.file("proguard/ComposeNativeTray.pro"), rootProject.file("proguard/compose.pro"), rootProject.file("proguard/jna.pro"), rootProject.file("proguard/ktor.pro"), diff --git a/app/src/main/kotlin/com/spoiligaming/explorer/ArgsParser.kt b/app/src/main/kotlin/com/spoiligaming/explorer/ArgsParser.kt index 26aa9d8..7376537 100644 --- a/app/src/main/kotlin/com/spoiligaming/explorer/ArgsParser.kt +++ b/app/src/main/kotlin/com/spoiligaming/explorer/ArgsParser.kt @@ -1,6 +1,6 @@ /* * This file is part of Server List Explorer. - * Copyright (C) 2025 SpoilerRules + * Copyright (C) 2025-2026 SpoilerRules * * Server List Explorer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,7 +23,20 @@ import org.apache.logging.log4j.Level import org.apache.logging.log4j.core.config.Configurator internal object ArgsParser { + private const val STARTUP_SOURCE_ARG_PREFIX = "--startup-source=" + private const val STARTUP_SOURCE_OS = "os" + + var isAutoStartupLaunch = false + private set + fun parse(args: Array) { + val startupSource = + args + .firstOrNull { it.startsWith(STARTUP_SOURCE_ARG_PREFIX) } + ?.substringAfter(STARTUP_SOURCE_ARG_PREFIX) + + isAutoStartupLaunch = startupSource == STARTUP_SOURCE_OS + if ("--verbose" in args) { enableVerboseLogging() } diff --git a/app/src/main/kotlin/com/spoiligaming/explorer/Main.kt b/app/src/main/kotlin/com/spoiligaming/explorer/Main.kt index 7bb1966..7ff2258 100644 --- a/app/src/main/kotlin/com/spoiligaming/explorer/Main.kt +++ b/app/src/main/kotlin/com/spoiligaming/explorer/Main.kt @@ -1,6 +1,6 @@ /* * This file is part of Server List Explorer. - * Copyright (C) 2025 SpoilerRules + * Copyright (C) 2025-2026 SpoilerRules * * Server List Explorer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,14 +18,43 @@ package com.spoiligaming.explorer +import com.kdroid.composetray.utils.SingleInstanceManager +import com.spoiligaming.explorer.settings.manager.startupSettingsManager import com.spoiligaming.explorer.ui.launchInterface +import com.spoiligaming.explorer.util.AppActivationSignal +import com.spoiligaming.explorer.util.ComputerStartupRegistrationManager +import io.github.oshai.kotlinlogging.KotlinLogging fun main(args: Array) { val env = if (System.getProperty("env") == "dev") "dev" else "prod" + val logsDir = LogStorage.logsDir + logsDir.mkdirs() System.setProperty("log4j2.configurationFile", "log4j2-$env.xml") - System.setProperty("app.logs.dir", LogStorage.logsDir.absolutePath) + System.setProperty("app.logs.dir", logsDir.absolutePath) ArgsParser.parse(args) + WindowsProcessPriority.applyAutoStartupPriority() - launchInterface() + val startupSettings = startupSettingsManager.getCachedSettings() + ComputerStartupRegistrationManager + .reconcile(startupSettings.computerStartupBehavior) + .onFailure { e -> + logger.error(e) { + "Failed to reconcile OS startup registration for behavior=${startupSettings.computerStartupBehavior}" + } + } + + if (startupSettings.singleInstanceHandling) { + val isPrimary = + SingleInstanceManager.isSingleInstance( + onRestoreRequest = { AppActivationSignal.publish() }, + ) + if (!isPrimary) { + return + } + } + + launchInterface(isAutoStartupLaunch = ArgsParser.isAutoStartupLaunch) } + +private val logger by lazy { KotlinLogging.logger {} } diff --git a/app/src/main/kotlin/com/spoiligaming/explorer/WindowsProcessPriority.kt b/app/src/main/kotlin/com/spoiligaming/explorer/WindowsProcessPriority.kt new file mode 100644 index 0000000..e97aa78 --- /dev/null +++ b/app/src/main/kotlin/com/spoiligaming/explorer/WindowsProcessPriority.kt @@ -0,0 +1,56 @@ +/* + * This file is part of Server List Explorer. + * Copyright (C) 2026 SpoilerRules + * + * Server List Explorer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Server List Explorer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Server List Explorer. If not, see . + */ + +package com.spoiligaming.explorer + +import com.spoiligaming.explorer.util.OSUtils +import com.sun.jna.platform.win32.Kernel32 +import com.sun.jna.platform.win32.WinDef.DWORD +import io.github.oshai.kotlinlogging.KotlinLogging + +internal object WindowsProcessPriority { + private const val WINDOWS_IDLE_PRIORITY_CLASS = 0x00000040 + + fun applyAutoStartupPriority() { + if (!OSUtils.isWindows || OSUtils.isRunningOnBareJvm) { + return + } + + if (!ArgsParser.isAutoStartupLaunch) { + return + } + + runCatching { + val currentProcess = Kernel32.INSTANCE.GetCurrentProcess() + val isApplied = + Kernel32.INSTANCE.SetPriorityClass( + currentProcess, + DWORD(WINDOWS_IDLE_PRIORITY_CLASS.toLong()), + ) + + if (!isApplied) { + val code = Kernel32.INSTANCE.GetLastError() + error("Failed to lower process priority for auto startup. Win32Error=$code") + } + }.onFailure { e -> + logger.error(e) { "Unable to apply low-priority mode for Windows auto startup." } + } + } +} + +private val logger = KotlinLogging.logger {} diff --git a/core/src/main/kotlin/com/spoiligaming/explorer/util/AppActivationSignal.kt b/core/src/main/kotlin/com/spoiligaming/explorer/util/AppActivationSignal.kt new file mode 100644 index 0000000..a2ff9e1 --- /dev/null +++ b/core/src/main/kotlin/com/spoiligaming/explorer/util/AppActivationSignal.kt @@ -0,0 +1,31 @@ +/* + * This file is part of Server List Explorer. + * Copyright (C) 2026 SpoilerRules + * + * Server List Explorer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Server List Explorer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Server List Explorer. If not, see . + */ + +package com.spoiligaming.explorer.util + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +object AppActivationSignal { + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events = _events.asSharedFlow() + + fun publish() { + _events.tryEmit(Unit) + } +} diff --git a/core/src/main/kotlin/com/spoiligaming/explorer/util/ComputerStartupRegistrationManager.kt b/core/src/main/kotlin/com/spoiligaming/explorer/util/ComputerStartupRegistrationManager.kt new file mode 100644 index 0000000..1da671b --- /dev/null +++ b/core/src/main/kotlin/com/spoiligaming/explorer/util/ComputerStartupRegistrationManager.kt @@ -0,0 +1,236 @@ +/* + * This file is part of Server List Explorer. + * Copyright (C) 2026 SpoilerRules + * + * Server List Explorer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Server List Explorer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Server List Explorer. If not, see . + */ + +package com.spoiligaming.explorer.util + +import com.spoiligaming.explorer.settings.model.ComputerStartupBehavior +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.File + +object ComputerStartupRegistrationManager { + private const val STARTUP_SOURCE_ARG = "--startup-source=os" + + private const val WINDOWS_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" + private const val WINDOWS_RUN_VALUE_NAME = "Server List Explorer" + + private const val LINUX_AUTOSTART_FILE_NAME = "server-list-explorer.desktop" + + private val linuxAutostartFile + get() = + File( + File(linuxConfigHomeDirectory, "autostart"), + LINUX_AUTOSTART_FILE_NAME, + ) + + private val linuxConfigHomeDirectory: File + get() = + System + .getenv("XDG_CONFIG_HOME") + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let(::File) + ?: File(System.getProperty("user.home"), ".config") + + @Suppress("NOTHING_TO_INLINE") + inline fun reconcile(behavior: ComputerStartupBehavior) = applyBehavior(behavior) + + fun applyBehavior(behavior: ComputerStartupBehavior) = + runCatching { + when { + OSUtils.isWindows -> applyWindowsBehavior(behavior).getOrThrow() + OSUtils.isLinux -> applyLinuxBehavior(behavior).getOrThrow() + else -> logger.debug { "Skipping startup registration because this platform is unsupported." } + } + } + + private fun applyWindowsBehavior(behavior: ComputerStartupBehavior) = + runCatching { + if (behavior == ComputerStartupBehavior.DoNotStart) { + removeWindowsRegistration() + return@runCatching + } + + val launcherPath = resolveLauncherPath().getOrThrow() + writeWindowsRegistration(launcherPath) + } + + private fun applyLinuxBehavior(behavior: ComputerStartupBehavior) = + runCatching { + if (behavior == ComputerStartupBehavior.DoNotStart) { + removeLinuxRegistration() + return@runCatching + } + + val launcherPath = resolveLauncherPath().getOrThrow() + writeLinuxRegistration(launcherPath) + } + + private fun resolveLauncherPath(): Result { + val jPackageLauncherPath = OSUtils.jPackageLauncherPath + if (jPackageLauncherPath != null) { + return Result.success(jPackageLauncherPath) + } + + val processCommand = + ProcessHandle + .current() + .info() + .command() + .orElse(null) + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { File(it) } + ?.takeIf { it.exists() && it.isFile } + ?.absolutePath + + if (processCommand == null) { + return Result.failure( + IllegalStateException("Unable to resolve launcher path from the current process."), + ) + } + + if (OSUtils.isRunningOnBareJvm) { + val message = + "Startup registration requires a packaged launcher, but current process is JVM binary: $processCommand" + logger.warn { message } + return Result.failure(IllegalStateException(message)) + } + + return Result.success(processCommand) + } + + private fun writeWindowsRegistration(launcherPath: String) { + val command = "\"${launcherPath.escapeForCommandArgument()}\" $STARTUP_SOURCE_ARG" + + val result = + executeCommand( + "reg", + "add", + WINDOWS_RUN_KEY, + "/v", + WINDOWS_RUN_VALUE_NAME, + "/t", + "REG_SZ", + "/d", + command, + "/f", + ) + + if (!result.isSuccess) { + error("Failed to register Windows startup entry: ${result.output.ifBlank { "unknown error" }}") + } + } + + private fun removeWindowsRegistration() { + val existsResult = + executeCommand( + "reg", + "query", + WINDOWS_RUN_KEY, + "/v", + WINDOWS_RUN_VALUE_NAME, + ) + + if (!existsResult.isSuccess) { + return + } + + val deleteResult = + executeCommand( + "reg", + "delete", + WINDOWS_RUN_KEY, + "/v", + WINDOWS_RUN_VALUE_NAME, + "/f", + ) + + if (!deleteResult.isSuccess) { + error("Failed to remove Windows startup entry: ${deleteResult.output.ifBlank { "unknown error" }}") + } + } + + private fun writeLinuxRegistration(launcherPath: String) { + val autostartFile = linuxAutostartFile + val autostartDirectory = autostartFile.parentFile + if (autostartDirectory != null && !autostartDirectory.exists()) { + val wasCreated = autostartDirectory.mkdirs() + if (!wasCreated && !autostartDirectory.exists()) { + error("Failed to create Linux autostart directory: ${autostartDirectory.absolutePath}") + } + } + + val startupCommand = "\"$launcherPath\" $STARTUP_SOURCE_ARG" + val desktopEntry = + """ + [Desktop Entry] + Type=Application + Version=1.0 + Name=Server List Explorer + Comment=Server List Explorer for Minecraft + Exec=$startupCommand + Categories=Utility;Network; + StartupNotify=true + Terminal=false + NoDisplay=false + X-GNOME-Autostart-enabled=true + """.trimIndent() + System.lineSeparator() + + runCatching { + autostartFile.writeText(desktopEntry) + }.onFailure { e -> + error("Failed to write Linux startup entry: ${e.message.orEmpty()}") + } + } + + private fun removeLinuxRegistration() { + val autostartFile = linuxAutostartFile + if (!autostartFile.exists()) { + return + } + + val wasDeleted = autostartFile.delete() + if (!wasDeleted && autostartFile.exists()) { + error("Failed to remove Linux startup entry: ${autostartFile.absolutePath}") + } + } + + private fun executeCommand(vararg command: String) = + runCatching { + val process = ProcessBuilder(*command).start() + val output = + process.inputStream.bufferedReader().readText() + process.errorStream.bufferedReader().readText() + val exitCode = process.waitFor() + CommandResult(exitCode = exitCode, output = output) + }.getOrElse { e -> + CommandResult(exitCode = -1, output = e.message.orEmpty()) + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun String.escapeForCommandArgument() = replace("\"", "\"\"") + + private data class CommandResult( + val exitCode: Int, + val output: String, + ) { + val isSuccess + get() = exitCode == 0 + } +} + +private val logger = KotlinLogging.logger("ComputerStartupRegistrationManager") diff --git a/core/src/main/kotlin/com/spoiligaming/explorer/util/OSUtils.kt b/core/src/main/kotlin/com/spoiligaming/explorer/util/OSUtils.kt index 580bd95..bdc00bb 100644 --- a/core/src/main/kotlin/com/spoiligaming/explorer/util/OSUtils.kt +++ b/core/src/main/kotlin/com/spoiligaming/explorer/util/OSUtils.kt @@ -1,6 +1,6 @@ /* * This file is part of Server List Explorer. - * Copyright (C) 2025 SpoilerRules + * Copyright (C) 2025-2026 SpoilerRules * * Server List Explorer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,7 +19,11 @@ package com.spoiligaming.explorer.util import oshi.SystemInfo +import java.io.File import java.util.Locale +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.readLines object OSUtils { private val systemInfo by lazy { SystemInfo() } @@ -27,6 +31,9 @@ object OSUtils { private val versionInfo get() = os.versionInfo + val totalPhysicalMemoryBytes + get() = runCatching { systemInfo.hardware.memory.total }.getOrDefault(0L) + sealed class OSType { data class Windows( val buildNumber: Int, @@ -117,6 +124,44 @@ object OSUtils { val isLinux get() = currentOSType is OSType.Linux + val jPackageLauncherPath: String? + get() = + System + .getProperty("jpackage.app-path") + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let(::File) + ?.takeIf { it.exists() && it.isFile } + ?.absolutePath + + val isJPackageLauncher + get() = jPackageLauncherPath != null + + val isRunningOnBareJvm: Boolean + get() { + if (isJPackageLauncher) { + return false + } + + val processCommand = + ProcessHandle + .current() + .info() + .command() + .orElse(null) + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let(::File) + ?.takeIf { it.exists() && it.isFile } + ?.absolutePath ?: return false + + val executableName = File(processCommand).name.lowercase(Locale.ROOT) + return executableName == "java" || + executableName == "java.exe" || + executableName == "javaw" || + executableName == "javaw.exe" + } + val windowsBuild get() = (currentOSType as? OSType.Windows)?.buildNumber ?: -1 @@ -160,7 +205,57 @@ object OSUtils { } } - val osSummary + val isDebian: Boolean + get() { + if (!isLinux) return false + + val id = linuxOsRelease["ID"].orEmpty() + if (id == "debian") { + return true + } + + val idLikeTokens = + linuxOsRelease["ID_LIKE"] + .orEmpty() + .split(Regex("[,\\s]+")) + .map { it.trim() } + .filter { it.isNotEmpty() } + if ("debian" in idLikeTokens) { + return true + } + + return linuxDistro.contains("debian", ignoreCase = true) + } + + private val linuxOsRelease by lazy { + val osReleasePath = + sequenceOf("/etc/os-release", "/usr/lib/os-release") + .map(::Path) + .firstOrNull { it.exists() } + ?: return@lazy emptyMap() + + runCatching { + osReleasePath + .readLines() + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") && it.contains('=') } + .associate { line -> + val (key, value) = line.split("=", limit = 2) + key.trim().uppercase(Locale.ROOT) to normalizeOsReleaseValue(value) + } + }.getOrElse { emptyMap() } + } + + private fun normalizeOsReleaseValue(value: String) = + value + .trim() + .removeSurrounding("\"") + .removeSurrounding("'") + .trim() + .lowercase(Locale.ROOT) + + val osSummary: String get() = when (val o = currentOSType) { is OSType.Windows -> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7550af0..33b084a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ log4j = "2.25.3" jna = "5.18.1" oshi = "6.9.3" themeDetector = "3.9.1" +composeNativeTray = "1.1.0" # Minecraft nbt = "6.1" @@ -55,6 +56,7 @@ materialKolor = { module = "com.materialkolor:material-kolor", version.ref = "ma color-picker = { module = "com.godaddy.android.colorpicker:compose-color-picker-jvm", version.ref = "colorPicker" } reorderable-jvm = { module = "sh.calvin.reorderable:reorderable-jvm", version.ref = "reorderable" } file-kit = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "fileKit" } +compose-native-tray = { module = "io.github.kdroidfilter:composenativetray", version.ref = "composeNativeTray" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-material3" } compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "compose-materialIconsExtended" } @@ -96,4 +98,4 @@ compose = { id = "org.jetbrains.compose", version.ref = "compose" } shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } buildConfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildConfig" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } -dependencyLicenseReport = { id = "com.github.jk1.dependency-license-report", version.ref = "licenseReport" } \ No newline at end of file +dependencyLicenseReport = { id = "com.github.jk1.dependency-license-report", version.ref = "licenseReport" } diff --git a/proguard/ComposeNativeTray.pro b/proguard/ComposeNativeTray.pro new file mode 100644 index 0000000..b6281c5 --- /dev/null +++ b/proguard/ComposeNativeTray.pro @@ -0,0 +1,4 @@ +# --- Source: https://github.com/kdroidFilter/ComposeNativeTray/blob/master/README.md + +-keep class com.sun.jna.** { *; } +-keep class com.kdroid.composetray.** { *; } \ No newline at end of file diff --git a/settings/src/main/kotlin/com/spoiligaming/explorer/settings/manager/SettingsManagers.kt b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/manager/SettingsManagers.kt index 4ebcde8..437da17 100644 --- a/settings/src/main/kotlin/com/spoiligaming/explorer/settings/manager/SettingsManagers.kt +++ b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/manager/SettingsManagers.kt @@ -1,6 +1,6 @@ /* * This file is part of Server List Explorer. - * Copyright (C) 2025 SpoilerRules + * Copyright (C) 2025-2026 SpoilerRules * * Server List Explorer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,6 +22,7 @@ import com.spoiligaming.explorer.settings.model.MultiplayerSettings import com.spoiligaming.explorer.settings.model.Preferences import com.spoiligaming.explorer.settings.model.ServerQueryMethodConfigurations import com.spoiligaming.explorer.settings.model.SingleplayerSettings +import com.spoiligaming.explorer.settings.model.StartupSettings import com.spoiligaming.explorer.settings.model.ThemeSettings import com.spoiligaming.explorer.settings.model.WindowAppearance import com.spoiligaming.explorer.settings.model.WindowState @@ -70,3 +71,8 @@ val singleplayerSettingsManager by UniversalSettingsManager( + fileName = "startup.json", + defaultValueProvider = { StartupSettings() }, +) diff --git a/settings/src/main/kotlin/com/spoiligaming/explorer/settings/model/StartupSettings.kt b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/model/StartupSettings.kt new file mode 100644 index 0000000..fabb8fc --- /dev/null +++ b/settings/src/main/kotlin/com/spoiligaming/explorer/settings/model/StartupSettings.kt @@ -0,0 +1,51 @@ +/* + * This file is part of Server List Explorer. + * Copyright (C) 2026 SpoilerRules + * + * Server List Explorer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Server List Explorer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Server List Explorer. If not, see . + */ + +package com.spoiligaming.explorer.settings.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class ComputerStartupBehavior { + @SerialName("do_not_start") + DoNotStart, + + @SerialName("start_visible") + StartVisible, + + @SerialName("start_minimized_to_system_tray") + StartMinimizedToSystemTray, +} + +@Serializable +data class StartupSettings( + @SerialName("computer_startup_behavior") + val computerStartupBehavior: ComputerStartupBehavior = ComputerStartupBehavior.DoNotStart, + @SerialName("minimize_to_system_tray_on_close") + val minimizeToSystemTrayOnClose: Boolean = true, + @SerialName("single_instance_handling") + val singleInstanceHandling: Boolean = true, + @SerialName("persistent_session_state") + val persistentSessionState: Boolean = false, +) { + val shouldStartMinimizedToSystemTray + get() = computerStartupBehavior == ComputerStartupBehavior.StartMinimizedToSystemTray + + val isSystemTrayFeatureEnabled get() = shouldStartMinimizedToSystemTray || minimizeToSystemTrayOnClose +} diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 19662d9..4942fc2 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -49,6 +49,9 @@ dependencies { implementation(libs.reorderable.jvm) implementation(libs.autolinktext) + // System tray UI components + implementation(libs.compose.native.tray) + // JNA for DWM implementation(libs.jna) implementation(libs.jna.platform) diff --git a/ui/src/main/composeResources/values-en-rGB/strings.xml b/ui/src/main/composeResources/values-en-rGB/strings.xml index 4953c51..808b4ec 100644 --- a/ui/src/main/composeResources/values-en-rGB/strings.xml +++ b/ui/src/main/composeResources/values-en-rGB/strings.xml @@ -2,18 +2,47 @@ Choose your preferred language Localisation + Start-up Experience Minecraft Path Configuration Back Next Finish + Open + Hide + Exit + System tray icon + Review the selected paths and continue Select the required paths to proceed Server list file already saved You already have saved server list file path(s). The set-up wizard can’t change them. Manage server list files from Multiplayer settings. Couldn't save the detected server list file Couldn't save the selected server list file + Choose how Server List Explorer behaves when your computer starts and when it closes + Choose how Server List Explorer behaves when it closes + Memory tip + This device has less than 8 GB of memory. Server List Explorer is expected to use at least 300 MB while idle in the system tray. + Low memory warning + Couldn't update startup preference. Please try again. + Start-up + When my computer starts + Choose how Server List Explorer starts when your computer starts. + When your computer starts, Server List Explorer will… + Do not start + Start visible + Start minimised to system tray + Couldn't update startup preference. Please try again. + Minimise to tray on close + Keep Server List Explorer running in the system tray when you close Server List Explorer. + Single-instance handling + Prevent duplicate windows and restore the existing one. + Restore previous state when reopened from system tray + Reopen where you left off instead of starting afresh. + When Server List Explorer is minimised to the system tray, reopening it returns you to the exact state you left. Turn this off to start afresh each time you bring it back from the tray. + Zero impact on system performance. Whether this is on or off, the memory footprint remains the same. Only enable this if you prefer a clean slate every time you open Server List Explorer. + Couldn't finish setup. Please try again. Set-up Wizard • Step %1$d of %2$d World Saves Folder diff --git a/ui/src/main/composeResources/values/strings.xml b/ui/src/main/composeResources/values/strings.xml index 252f12d..b35cfcc 100644 --- a/ui/src/main/composeResources/values/strings.xml +++ b/ui/src/main/composeResources/values/strings.xml @@ -2,18 +2,47 @@ Choose your preferred language Localization + Startup Experience Minecraft Path Configuration Back Next Finish + Open + Hide + Exit + System tray icon + Review the selected paths and continue Select the required paths to proceed Server list file already saved You already have saved server list file path(s). The setup wizard can’t change them. Manage server list files from Multiplayer settings. Couldn't save the detected server list file Couldn't save the selected server list file + Choose how Server List Explorer behaves when your computer starts and when it closes + Choose how Server List Explorer behaves when it closes + Memory tip + This device has less than 8 GB of memory. Server List Explorer is expected to use at least 300 MB while idle in the system tray. + Low memory warning + Couldn't update startup preference. Please try again. + Startup + When my computer starts + Choose how Server List Explorer starts when your computer starts. + When your computer starts, Server List Explorer will... + Do not start + Start visible + Start minimized to system tray + Couldn't update startup preference. Please try again. + Minimize to tray on close + Keep Server List Explorer running in the system tray when you close Server List Explorer. + Single-instance handling + Prevent duplicate windows and restore the existing one. + Restore previous state when reopened from system tray + Reopen where you left off instead of starting with a fresh session. + When Server List Explorer is minimized to the system tray, reopening it returns you to the exact state you left. Turn this off to start fresh each time you bring it back from the tray. + Zero impact on system performance. Whether this is on or off, the memory footprint remains the same. Only enable this if you prefer a clean slate every time you open Server List Explorer. + Couldn't finish setup. Please try again. Setup Wizard • Step %1$d of %2$d World Saves Folder diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/InterfaceBootstrap.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/InterfaceBootstrap.kt index c4856cf..1b69881 100644 --- a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/InterfaceBootstrap.kt +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/InterfaceBootstrap.kt @@ -1,6 +1,6 @@ /* * This file is part of Server List Explorer. - * Copyright (C) 2025 SpoilerRules + * Copyright (C) 2025-2026 SpoilerRules * * Server List Explorer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -64,8 +64,10 @@ import com.spoiligaming.explorer.ui.window.WindowManager import com.spoiligaming.explorer.util.FirstRunManager import kotlinx.coroutines.launch -fun launchInterface() { - WindowManager.launch { AppContainer() } +fun launchInterface(isAutoStartupLaunch: Boolean) { + WindowManager.launch(isAutoStartupLaunch = isAutoStartupLaunch) { + AppContainer() + } } @Composable diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/SettingsCompositionLocals.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/SettingsCompositionLocals.kt index 436b38b..baa21f6 100644 --- a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/SettingsCompositionLocals.kt +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/SettingsCompositionLocals.kt @@ -1,6 +1,6 @@ /* * This file is part of Server List Explorer. - * Copyright (C) 2025 SpoilerRules + * Copyright (C) 2025-2026 SpoilerRules * * Server List Explorer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,6 +28,7 @@ import com.spoiligaming.explorer.settings.manager.multiplayerSettingsManager import com.spoiligaming.explorer.settings.manager.preferenceSettingsManager import com.spoiligaming.explorer.settings.manager.serverQueryMethodConfigurationsManager import com.spoiligaming.explorer.settings.manager.singleplayerSettingsManager +import com.spoiligaming.explorer.settings.manager.startupSettingsManager import com.spoiligaming.explorer.settings.manager.themeSettingsManager import com.spoiligaming.explorer.settings.manager.windowAppearanceSettingsManager import com.spoiligaming.explorer.settings.manager.windowStateSettingsManager @@ -35,6 +36,7 @@ import com.spoiligaming.explorer.settings.model.MultiplayerSettings import com.spoiligaming.explorer.settings.model.Preferences import com.spoiligaming.explorer.settings.model.ServerQueryMethodConfigurations import com.spoiligaming.explorer.settings.model.SingleplayerSettings +import com.spoiligaming.explorer.settings.model.StartupSettings import com.spoiligaming.explorer.settings.model.ThemeMode import com.spoiligaming.explorer.settings.model.ThemeSettings import com.spoiligaming.explorer.settings.model.WindowAppearance @@ -74,6 +76,11 @@ internal val LocalSingleplayerSettings = staticCompositionLocalOf { error("LocalSingleplayerSettings not provided") } + +internal val LocalStartupSettings = + staticCompositionLocalOf { + error("LocalStartupSettings not provided") + } internal val LocalAmoledActive = compositionLocalOf { false } @Composable @@ -85,6 +92,7 @@ internal fun ProvideAppSettings(content: @Composable () -> Unit) { val multiplayerSettings by multiplayerSettingsManager.settingsFlow.collectAsState() val serverQueryMethodConfigurations by serverQueryMethodConfigurationsManager.settingsFlow.collectAsState() val singleplayerSettings by singleplayerSettingsManager.settingsFlow.collectAsState() + val startupSettings by startupSettingsManager.settingsFlow.collectAsState() val amoledOn = themeSettings.amoledMode && themeSettings.themeMode != ThemeMode.Light @@ -96,6 +104,7 @@ internal fun ProvideAppSettings(content: @Composable () -> Unit) { LocalMultiplayerSettings provides multiplayerSettings, LocalServerQueryMethodConfigurations provides serverQueryMethodConfigurations, LocalSingleplayerSettings provides singleplayerSettings, + LocalStartupSettings provides startupSettings, LocalAmoledActive provides amoledOn, ) { content() diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/settings/SettingsScreen.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/settings/SettingsScreen.kt index ac08f22..a7c6b8e 100644 --- a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/settings/SettingsScreen.kt +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/settings/SettingsScreen.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.unit.times import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalPrefs import com.spoiligaming.explorer.ui.screens.settings.sections.MultiplayerSettings import com.spoiligaming.explorer.ui.screens.settings.sections.PreferenceSettings +import com.spoiligaming.explorer.ui.screens.settings.sections.StartupSettingsSection import com.spoiligaming.explorer.ui.screens.settings.sections.ThemeSettings import com.spoiligaming.explorer.ui.screens.settings.sections.WindowAppearenceSettings import com.spoiligaming.explorer.ui.t @@ -79,6 +80,7 @@ import server_list_explorer.ui.generated.resources.open_source_licenses_button import server_list_explorer.ui.generated.resources.settings_navigator_title import server_list_explorer.ui.generated.resources.settings_section_multiplayer import server_list_explorer.ui.generated.resources.settings_section_preferences +import server_list_explorer.ui.generated.resources.settings_section_startup import server_list_explorer.ui.generated.resources.settings_section_theme import server_list_explorer.ui.generated.resources.settings_section_window_appearance import kotlin.math.absoluteValue @@ -93,6 +95,7 @@ internal fun SettingsScreen() { add(@Composable { t(Res.string.settings_section_theme) } to { ThemeSettings() }) add(@Composable { t(Res.string.settings_section_preferences) } to { PreferenceSettings() }) add(@Composable { t(Res.string.settings_section_multiplayer) } to { MultiplayerSettings() }) + add(@Composable { t(Res.string.settings_section_startup) } to { StartupSettingsSection() }) if (OSUtils.isWindows) { add( @Composable { t(Res.string.settings_section_window_appearance) } to diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/settings/sections/StartupSettingsSection.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/settings/sections/StartupSettingsSection.kt new file mode 100644 index 0000000..ce4454c --- /dev/null +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/settings/sections/StartupSettingsSection.kt @@ -0,0 +1,157 @@ +/* + * This file is part of Server List Explorer. + * Copyright (C) 2026 SpoilerRules + * + * Server List Explorer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Server List Explorer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Server List Explorer. If not, see . + */ + +package com.spoiligaming.explorer.ui.screens.settings.sections + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import com.spoiligaming.explorer.settings.manager.startupSettingsManager +import com.spoiligaming.explorer.settings.model.ComputerStartupBehavior +import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalStartupSettings +import com.spoiligaming.explorer.ui.screens.settings.components.SettingsSection +import com.spoiligaming.explorer.ui.snackbar.SnackbarController +import com.spoiligaming.explorer.ui.snackbar.SnackbarEvent +import com.spoiligaming.explorer.ui.t +import com.spoiligaming.explorer.ui.util.displayNameResource +import com.spoiligaming.explorer.ui.widgets.DropdownOption +import com.spoiligaming.explorer.ui.widgets.ItemSelectableDropdownMenu +import com.spoiligaming.explorer.ui.widgets.ItemSwitch +import com.spoiligaming.explorer.util.ComputerStartupRegistrationManager +import com.spoiligaming.explorer.util.OSUtils +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.launch +import server_list_explorer.ui.generated.resources.Res +import server_list_explorer.ui.generated.resources.settings_section_startup +import server_list_explorer.ui.generated.resources.startup_minimize_to_tray_on_close_description +import server_list_explorer.ui.generated.resources.startup_minimize_to_tray_on_close_title +import server_list_explorer.ui.generated.resources.startup_restore_previous_state_description_long +import server_list_explorer.ui.generated.resources.startup_restore_previous_state_note +import server_list_explorer.ui.generated.resources.startup_restore_previous_state_title +import server_list_explorer.ui.generated.resources.startup_save_failed +import server_list_explorer.ui.generated.resources.startup_single_instance_handling_description +import server_list_explorer.ui.generated.resources.startup_single_instance_handling_title +import server_list_explorer.ui.generated.resources.startup_when_computer_starts_dropdown_description +import server_list_explorer.ui.generated.resources.startup_when_computer_starts_dropdown_title + +@Composable +internal fun StartupSettingsSection() { + val startupSettings = LocalStartupSettings.current + val scope = rememberCoroutineScope() + val startupSaveFailedMessage = t(Res.string.startup_save_failed) + + SettingsSection( + header = t(Res.string.settings_section_startup), + settings = + buildList { + if ((OSUtils.isWindows || OSUtils.isDebian) && !OSUtils.isRunningOnBareJvm) { + add { + ComputerStartupBehaviorDropdown( + currentMode = startupSettings.computerStartupBehavior, + onModeSelected = { newBehavior -> + if (newBehavior == startupSettings.computerStartupBehavior) { + return@ComputerStartupBehaviorDropdown + } + + scope.launch { + ComputerStartupRegistrationManager + .applyBehavior(newBehavior) + .onSuccess { + startupSettingsManager.updateSettings { + it.copy(computerStartupBehavior = newBehavior) + } + }.onFailure { e -> + logger.error(e) { + "Failed to apply computer startup behavior: $newBehavior" + } + SnackbarController.sendEvent( + SnackbarEvent( + message = startupSaveFailedMessage, + duration = SnackbarDuration.Short, + ), + ) + } + } + }, + ) + } + } + add { + ItemSwitch( + title = t(Res.string.startup_minimize_to_tray_on_close_title), + description = t(Res.string.startup_minimize_to_tray_on_close_description), + isChecked = startupSettings.minimizeToSystemTrayOnClose, + onCheckedChange = { newValue -> + startupSettingsManager.updateSettings { + it.copy(minimizeToSystemTrayOnClose = newValue) + } + }, + ) + } + add { + ItemSwitch( + title = t(Res.string.startup_single_instance_handling_title), + description = t(Res.string.startup_single_instance_handling_description), + isChecked = startupSettings.singleInstanceHandling, + onCheckedChange = { newValue -> + startupSettingsManager.updateSettings { + it.copy(singleInstanceHandling = newValue) + } + }, + ) + } + add { + ItemSwitch( + title = t(Res.string.startup_restore_previous_state_title), + description = t(Res.string.startup_restore_previous_state_description_long), + note = t(Res.string.startup_restore_previous_state_note), + isChecked = startupSettings.persistentSessionState, + onCheckedChange = { newValue -> + startupSettingsManager.updateSettings { + it.copy(persistentSessionState = newValue) + } + }, + ) + } + }, + ) +} + +@Composable +private fun ComputerStartupBehaviorDropdown( + currentMode: ComputerStartupBehavior, + onModeSelected: (ComputerStartupBehavior) -> Unit, +) { + val startupModes = ComputerStartupBehavior.entries + val options = + startupModes.map { mode -> + DropdownOption(text = t(mode.displayNameResource)) + } + val selectedOption = options[startupModes.indexOf(currentMode)] + + ItemSelectableDropdownMenu( + title = t(Res.string.startup_when_computer_starts_dropdown_title), + description = t(Res.string.startup_when_computer_starts_dropdown_description), + selectedOption = selectedOption, + options = options, + ) { selected -> + startupModes.getOrNull(options.indexOf(selected))?.let(onModeSelected) + } +} + +private val logger = KotlinLogging.logger {} diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/SetupScreen.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/SetupScreen.kt index 3b3dee2..1324105 100644 --- a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/SetupScreen.kt +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/SetupScreen.kt @@ -1,6 +1,6 @@ /* * This file is part of Server List Explorer. - * Copyright (C) 2025 SpoilerRules + * Copyright (C) 2025-2026 SpoilerRules * * Server List Explorer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -37,7 +37,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -46,6 +48,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -54,23 +57,41 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.spoiligaming.explorer.serverlist.bookmarks.ServerListFileBookmarksManager +import com.spoiligaming.explorer.settings.manager.UniversalSettingsManager +import com.spoiligaming.explorer.settings.manager.preferenceSettingsManager +import com.spoiligaming.explorer.settings.manager.singleplayerSettingsManager +import com.spoiligaming.explorer.settings.manager.startupSettingsManager +import com.spoiligaming.explorer.settings.model.ComputerStartupBehavior +import com.spoiligaming.explorer.settings.model.StartupSettings +import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalPrefs import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalSingleplayerSettings +import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalStartupSettings import com.spoiligaming.explorer.ui.screens.setup.steps.LanguageSelectionStep import com.spoiligaming.explorer.ui.screens.setup.steps.PathStep +import com.spoiligaming.explorer.ui.screens.setup.steps.StartupStep +import com.spoiligaming.explorer.ui.snackbar.SnackbarController +import com.spoiligaming.explorer.ui.snackbar.SnackbarEvent import com.spoiligaming.explorer.ui.t +import com.spoiligaming.explorer.util.ComputerStartupRegistrationManager +import com.spoiligaming.explorer.util.OSUtils +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import server_list_explorer.ui.generated.resources.Res import server_list_explorer.ui.generated.resources.button_back import server_list_explorer.ui.generated.resources.button_finish import server_list_explorer.ui.generated.resources.button_next +import server_list_explorer.ui.generated.resources.setup_wizard_finish_failed import server_list_explorer.ui.generated.resources.setup_wizard_step_counter import java.nio.file.Path +import java.util.Locale internal enum class SetupStep( val order: Int, ) { LANGUAGE_SELECTION(0), - PATH_CONFIGURATION(1), + STARTUP_CONFIGURATION(1), + PATH_CONFIGURATION(2), ; companion object { @@ -95,10 +116,16 @@ internal enum class SetupStep( @Stable internal class SetupUiState( + initialLocale: Locale, + initialStartupSettings: StartupSettings, initialWorldSavesPath: Path?, initialServerFilePath: Path?, ) { var currentStep by mutableStateOf(SetupStep.firstStep) + var isFinishing by mutableStateOf(false) + + var locale by mutableStateOf(initialLocale) + var startupSettings by mutableStateOf(initialStartupSettings) var worldSavesPath by mutableStateOf(initialWorldSavesPath) var serverFilePath by mutableStateOf(initialServerFilePath) @@ -127,6 +154,7 @@ internal class SetupUiState( when (currentStep) { SetupStep.PATH_CONFIGURATION -> worldSavesPath != null && serverFilePath != null SetupStep.LANGUAGE_SELECTION -> true + SetupStep.STARTUP_CONFIGURATION -> true } } @@ -136,8 +164,13 @@ internal fun SetupWizard( intOffsetAnimationSpec: FiniteAnimationSpec, floatAnimationSpec: FiniteAnimationSpec, ) { + val prefs = LocalPrefs.current val sp = LocalSingleplayerSettings.current + val startupSettings = LocalStartupSettings.current + val scope = rememberCoroutineScope() val activeServerListFilePath by ServerListFileBookmarksManager.activePath.collectAsState() + val setupWizardFinishFailedMessage = t(Res.string.setup_wizard_finish_failed) + val supportsStartupRegistration = (OSUtils.isWindows || OSUtils.isDebian) && !OSUtils.isRunningOnBareJvm LaunchedEffect(Unit) { ServerListFileBookmarksManager.load() @@ -146,41 +179,92 @@ internal fun SetupWizard( val state = remember { SetupUiState( + initialLocale = prefs.locale, + initialStartupSettings = + resolveInitialSetupStartupSettings( + startupSettings = startupSettings, + supportsStartupRegistration = supportsStartupRegistration, + ), initialWorldSavesPath = sp.savesDirectory, initialServerFilePath = activeServerListFilePath, ) } LaunchedEffect(activeServerListFilePath) { - state.serverFilePath = activeServerListFilePath + if (state.serverFilePath == null && activeServerListFilePath != null) { + state.serverFilePath = activeServerListFilePath + } } - Box(Modifier.fillMaxSize()) { - Box( - modifier = - Modifier - .fillMaxSize() - .padding(bottom = BottomStatusPadding), - contentAlignment = Alignment.BottomCenter, - ) { - Text( - text = - stringResource( - Res.string.setup_wizard_step_counter, - state.currentStep.getStepNumber(), - SetupStep.totalSteps, - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + suspend fun persistSetupState() = + runCatching { + if (prefs.locale != state.locale) { + persistSettingsUpdate( + manager = preferenceSettingsManager, + context = "locale setting", + update = { it.copy(locale = state.locale) }, + isPersisted = { it.locale == state.locale }, + ) + } + + if (sp.savesDirectory != state.worldSavesPath) { + persistSettingsUpdate( + manager = singleplayerSettingsManager, + context = "singleplayer saves directory setting", + update = { it.copy(savesDirectory = state.worldSavesPath) }, + isPersisted = { it.savesDirectory == state.worldSavesPath }, + ) + } + + if (startupSettings.computerStartupBehavior != state.startupSettings.computerStartupBehavior) { + ComputerStartupRegistrationManager + .applyBehavior(state.startupSettings.computerStartupBehavior) + .getOrThrow() + } + if (startupSettings != state.startupSettings) { + persistSettingsUpdate( + manager = startupSettingsManager, + context = "startup settings", + update = { state.startupSettings }, + isPersisted = { it == state.startupSettings }, + ) + } + + val selectedServerFilePath = state.serverFilePath + if (selectedServerFilePath != null && selectedServerFilePath != activeServerListFilePath) { + ServerListFileBookmarksManager.setActivePath(selectedServerFilePath) + } + }.onFailure { e -> + logger.error(e) { "Failed to finalize setup wizard settings." } + SnackbarController.sendEvent( + SnackbarEvent( + message = setupWizardFinishFailedMessage, + duration = SnackbarDuration.Short, + ), ) + }.isSuccess + + fun finalizeSetup() { + if (state.isFinishing) { + return + } + state.isFinishing = true + scope.launch { + try { + if (persistSetupState()) { + onFinished() + } + } finally { + state.isFinishing = false + } } + } + Box(Modifier.fillMaxSize()) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(VerticalSpacing), ) { - SetupProgressBar(state, floatAnimationSpec) - Box( modifier = Modifier @@ -207,15 +291,17 @@ internal fun SetupWizard( }, ) { targetStep -> when (targetStep) { - SetupStep.LANGUAGE_SELECTION -> LanguageSelectionStep() + SetupStep.LANGUAGE_SELECTION -> LanguageSelectionStep(state = state) + SetupStep.STARTUP_CONFIGURATION -> StartupStep(state = state) SetupStep.PATH_CONFIGURATION -> PathStep(state = state) } } } + SetupProgressBar(state, floatAnimationSpec) NavigationControls( state = state, - onFinished = onFinished, + onFinished = ::finalizeSetup, ) } } @@ -247,51 +333,94 @@ private fun NavigationControls( state: SetupUiState, onFinished: () -> Unit, modifier: Modifier = Modifier, -) = Row( +) = Box( modifier = modifier .fillMaxWidth() .padding(start = ScreenPadding, end = ScreenPadding, bottom = ScreenPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, ) { - if (!state.currentStep.isFirst()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (!state.currentStep.isFirst()) { + OutlinedButton( + onClick = { state.navigateToPrevious() }, + modifier = Modifier.pointerHoverIcon(if (!state.isFinishing) PointerIcon.Hand else PointerIcon.Default), + enabled = !state.isFinishing, + ) { + Text(t(Res.string.button_back)) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + val isCurrentStepValid = state.isCurrentStepValid() && !state.isFinishing + Button( - onClick = { state.navigateToPrevious() }, - modifier = Modifier.pointerHoverIcon(PointerIcon.Hand), + onClick = { + if (!state.currentStep.isLast()) { + state.navigateToNext() + } else { + onFinished() + } + }, + modifier = + Modifier.pointerHoverIcon( + if (isCurrentStepValid) PointerIcon.Hand else PointerIcon.Default, + ), + enabled = isCurrentStepValid, ) { - Text(t(Res.string.button_back)) + Text( + if (state.currentStep.isLast()) { + t(Res.string.button_finish) + } else { + t(Res.string.button_next) + }, + ) } } + Text( + text = + stringResource( + Res.string.setup_wizard_step_counter, + state.currentStep.getStepNumber(), + SetupStep.totalSteps, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center), + ) +} - Spacer(modifier = Modifier.weight(1f)) - - val isCurrentStepValid = state.isCurrentStepValid() +private fun resolveInitialSetupStartupSettings( + startupSettings: StartupSettings, + supportsStartupRegistration: Boolean, +) = if ( + supportsStartupRegistration && + startupSettings.computerStartupBehavior == ComputerStartupBehavior.DoNotStart +) { + startupSettings.copy( + computerStartupBehavior = ComputerStartupBehavior.StartMinimizedToSystemTray, + ) +} else { + startupSettings +} - Button( - onClick = { - if (!state.currentStep.isLast()) { - state.navigateToNext() - } else { - onFinished() - } - }, - modifier = - Modifier.pointerHoverIcon( - if (isCurrentStepValid) PointerIcon.Hand else PointerIcon.Default, - ), - enabled = isCurrentStepValid, - ) { - Text( - if (state.currentStep.isLast()) { - t(Res.string.button_finish) - } else { - t(Res.string.button_next) - }, - ) +private suspend fun persistSettingsUpdate( + manager: UniversalSettingsManager, + context: String, + update: (T) -> T, + isPersisted: (T) -> Boolean, +) { + manager.updateSettings(update).join() + check(isPersisted(manager.settingsFlow.value)) { + "Failed to persist $context." } } private val ScreenPadding = 16.dp private val VerticalSpacing = 16.dp -private val BottomStatusPadding = 16.dp + +private val logger = KotlinLogging.logger {} diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/SetupStepContainer.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/SetupStepContainer.kt index 19c38db..79cdcef 100644 --- a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/SetupStepContainer.kt +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/SetupStepContainer.kt @@ -1,6 +1,6 @@ /* * This file is part of Server List Explorer. - * Copyright (C) 2025 SpoilerRules + * Copyright (C) 2025-2026 SpoilerRules * * Server List Explorer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,10 +22,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -33,6 +40,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalPrefs +import com.spoiligaming.explorer.ui.widgets.AppVerticalScrollbar @Composable internal fun SetupStepContainer( @@ -43,6 +52,11 @@ internal fun SetupStepContainer( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { + val prefs = LocalPrefs.current + val scrollState = rememberScrollState() + val scrollbarAdapter = rememberScrollbarAdapter(scrollState) + val isScrollable = scrollState.canScrollForward || scrollState.canScrollBackward + Card( modifier = Modifier @@ -54,21 +68,47 @@ internal fun SetupStepContainer( verticalArrangement = Arrangement.spacedBy(ColumnArrangement), ) { Column { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - subtitle?.let { + SelectionContainer { Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, ) } + subtitle?.let { + SelectionContainer { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } - Box { - content() + + Row( + modifier = if (isScrollable) Modifier.fillMaxWidth() else Modifier.wrapContentWidth(), + horizontalArrangement = Arrangement.spacedBy(ContentRowSpacing), + ) { + Box( + modifier = + if (isScrollable) { + Modifier + .weight(ContentWeight) + .verticalScroll(scrollState) + } else { + Modifier.verticalScroll(scrollState) + }, + ) { + content() + } + + if (isScrollable) { + AppVerticalScrollbar( + adapter = scrollbarAdapter, + alwaysVisible = prefs.settingsScrollbarAlwaysVisible, + ) + } } } } @@ -76,5 +116,7 @@ internal fun SetupStepContainer( private val ContainerPadding = 32.dp private val ColumnArrangement = 16.dp +private val ContentRowSpacing = 4.dp private val ContainerMinWidth = 400.dp private val ContainerMaxWidth = 800.dp +private const val ContentWeight = 1f diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/components/OnboardingSettingTile.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/components/OnboardingSettingTile.kt new file mode 100644 index 0000000..879cdcf --- /dev/null +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/components/OnboardingSettingTile.kt @@ -0,0 +1,72 @@ +/* + * This file is part of Server List Explorer. + * Copyright (C) 2026 SpoilerRules + * + * Server List Explorer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Server List Explorer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Server List Explorer. If not, see . + */ + +package com.spoiligaming.explorer.ui.screens.setup.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +internal fun OnboardingSettingTile( + title: String, + description: String? = null, + trailingContent: @Composable () -> Unit, + modifier: Modifier = Modifier, +) = Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(OnboardingTileContentSpacing), +) { + Column( + modifier = Modifier.weight(ONBOARDING_TILE_TEXT_WEIGHT), + verticalArrangement = Arrangement.spacedBy(OnboardingTileTextSpacing), + ) { + SelectionContainer { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + description?.let { + SelectionContainer { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + trailingContent() +} + +private val OnboardingTileContentSpacing = 16.dp +private val OnboardingTileTextSpacing = 2.dp +private const val ONBOARDING_TILE_TEXT_WEIGHT = 1f diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/LanguageSelectionStep.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/LanguageSelectionStep.kt index 8c8a4a3..2df16ed 100644 --- a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/LanguageSelectionStep.kt +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/LanguageSelectionStep.kt @@ -27,9 +27,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp -import com.spoiligaming.explorer.settings.manager.preferenceSettingsManager -import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalPrefs import com.spoiligaming.explorer.ui.screens.setup.SetupStepContainer +import com.spoiligaming.explorer.ui.screens.setup.SetupUiState import com.spoiligaming.explorer.ui.t import com.spoiligaming.explorer.ui.widgets.LanguagePickerDropdownMenu import server_list_explorer.ui.generated.resources.Res @@ -37,9 +36,7 @@ import server_list_explorer.ui.generated.resources.preferred_language_label import server_list_explorer.ui.generated.resources.setup_step_title_localization @Composable -internal fun LanguageSelectionStep() { - val currentLocale = LocalPrefs.current.locale - +internal fun LanguageSelectionStep(state: SetupUiState) { SetupStepContainer(title = t(Res.string.setup_step_title_localization)) { Column( verticalArrangement = Arrangement.spacedBy(LanguageStepItemSpacing), @@ -50,11 +47,9 @@ internal fun LanguageSelectionStep() { color = MaterialTheme.colorScheme.onSurfaceVariant, ) LanguagePickerDropdownMenu( - selectedLocale = currentLocale, + selectedLocale = state.locale, onLocaleSelected = { locale -> - preferenceSettingsManager.updateSettings { - it.copy(locale = locale) - } + state.locale = locale }, ) } diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/PathStep.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/PathStep.kt index 97f8d87..7f37804 100644 --- a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/PathStep.kt +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/PathStep.kt @@ -1,6 +1,6 @@ /* * This file is part of Server List Explorer. - * Copyright (C) 2025 SpoilerRules + * Copyright (C) 2025-2026 SpoilerRules * * Server List Explorer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -40,7 +40,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -49,7 +48,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,17 +58,13 @@ import androidx.compose.ui.unit.dp import com.spoiligaming.explorer.minecraft.common.IModuleKind import com.spoiligaming.explorer.minecraft.common.UnifiedModeInitializer import com.spoiligaming.explorer.serverlist.bookmarks.ServerListFileBookmarksManager -import com.spoiligaming.explorer.settings.manager.singleplayerSettingsManager import com.spoiligaming.explorer.ui.extensions.onHover import com.spoiligaming.explorer.ui.screens.setup.SetupStepContainer import com.spoiligaming.explorer.ui.screens.setup.SetupUiState -import com.spoiligaming.explorer.ui.snackbar.SnackbarController -import com.spoiligaming.explorer.ui.snackbar.SnackbarEvent import com.spoiligaming.explorer.ui.t import com.spoiligaming.explorer.ui.util.rememberServerListFilePickerLauncher import com.spoiligaming.explorer.ui.util.rememberWorldSavesPickerLauncher import io.github.oshai.kotlinlogging.KotlinLogging -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import server_list_explorer.ui.generated.resources.Res import server_list_explorer.ui.generated.resources.cd_browse @@ -91,23 +85,15 @@ import server_list_explorer.ui.generated.resources.setup_path_step_missing import server_list_explorer.ui.generated.resources.setup_path_step_ready import server_list_explorer.ui.generated.resources.setup_server_list_path_locked_message import server_list_explorer.ui.generated.resources.setup_server_list_path_locked_title -import server_list_explorer.ui.generated.resources.setup_server_list_path_save_detected_failed -import server_list_explorer.ui.generated.resources.setup_server_list_path_save_selected_failed import server_list_explorer.ui.generated.resources.setup_step_title_paths @Composable internal fun PathStep(state: SetupUiState) { var isDetectingServer by remember { mutableStateOf(true) } var isDetectingSaves by remember { mutableStateOf(true) } - val scope = rememberCoroutineScope() val bookmarkEntries by ServerListFileBookmarksManager.entries.collectAsState() val isServerListFilePathSelectionLocked = bookmarkEntries.isNotEmpty() - // detected - val saveDetectedServerListFailedMessage = t(Res.string.setup_server_list_path_save_detected_failed) - // selected - val saveSelectedServerListFailedMessage = t(Res.string.setup_server_list_path_save_selected_failed) - LaunchedEffect(state.serverFilePath) { if (state.serverFilePath == null) { logger.info { "Attempting automatic server file path detection..." } @@ -118,16 +104,6 @@ internal fun PathStep(state: SetupUiState) { if (detected != null) { logger.info { "Server file path detected automatically: $detected" } - runCatching { ServerListFileBookmarksManager.setActivePath(detected) } - .onFailure { e -> - logger.error(e) { "Failed to store detected server list file path" } - SnackbarController.sendEvent( - SnackbarEvent( - message = saveDetectedServerListFailedMessage, - duration = SnackbarDuration.Short, - ), - ) - } state.serverFilePath = detected } else { logger.warn { "Automatic server file detection failed." } @@ -148,9 +124,6 @@ internal fun PathStep(state: SetupUiState) { if (detected != null) { logger.info { "World saves path detected automatically: $detected" } - singleplayerSettingsManager.updateSettings { - it.copy(savesDirectory = detected) - } state.worldSavesPath = detected } else { logger.warn { "Automatic world saves detection failed." } @@ -165,9 +138,6 @@ internal fun PathStep(state: SetupUiState) { rememberWorldSavesPickerLauncher( title = t(Res.string.placeholder_world_saves), ) { path -> - singleplayerSettingsManager.updateSettings { - it.copy(savesDirectory = path) - } state.worldSavesPath = path } @@ -175,20 +145,7 @@ internal fun PathStep(state: SetupUiState) { rememberServerListFilePickerLauncher( title = t(Res.string.placeholder_server_list), ) { path -> - scope.launch { - runCatching { ServerListFileBookmarksManager.setActivePath(path) } - .onFailure { e -> - logger.error(e) { "Failed to save selected server list file path" } - SnackbarController.sendEvent( - SnackbarEvent( - message = saveSelectedServerListFailedMessage, - duration = SnackbarDuration.Short, - ), - ) - }.onSuccess { - state.serverFilePath = path - } - } + state.serverFilePath = path } SetupStepContainer( @@ -385,16 +342,20 @@ private fun SetupServerListPathLockedNotice(modifier: Modifier = Modifier) = modifier = Modifier.size(LockedNoticeIconSize), ) Column(verticalArrangement = Arrangement.spacedBy(LockedNoticeTextSpacing)) { - Text( - text = t(Res.string.setup_server_list_path_locked_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = t(Res.string.setup_server_list_path_locked_message), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + SelectionContainer { + Text( + text = t(Res.string.setup_server_list_path_locked_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + SelectionContainer { + Text( + text = t(Res.string.setup_server_list_path_locked_message), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/StartupStep.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/StartupStep.kt new file mode 100644 index 0000000..9b2290d --- /dev/null +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/screens/setup/steps/StartupStep.kt @@ -0,0 +1,242 @@ +/* + * This file is part of Server List Explorer. + * Copyright (C) 2026 SpoilerRules + * + * Server List Explorer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Server List Explorer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Server List Explorer. If not, see . + */ + +package com.spoiligaming.explorer.ui.screens.setup.steps + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import com.spoiligaming.explorer.settings.model.ComputerStartupBehavior +import com.spoiligaming.explorer.ui.screens.setup.SetupStepContainer +import com.spoiligaming.explorer.ui.screens.setup.SetupUiState +import com.spoiligaming.explorer.ui.screens.setup.widgets.OnboardingItemSwitch +import com.spoiligaming.explorer.ui.t +import com.spoiligaming.explorer.ui.util.displayNameResource +import com.spoiligaming.explorer.util.OSUtils +import server_list_explorer.ui.generated.resources.Res +import server_list_explorer.ui.generated.resources.setup_startup_low_memory_icon_content_description +import server_list_explorer.ui.generated.resources.setup_startup_low_memory_message +import server_list_explorer.ui.generated.resources.setup_startup_low_memory_title +import server_list_explorer.ui.generated.resources.setup_startup_step_subtitle +import server_list_explorer.ui.generated.resources.setup_startup_step_subtitle_close_behavior +import server_list_explorer.ui.generated.resources.setup_step_title_startup +import server_list_explorer.ui.generated.resources.startup_minimize_to_tray_on_close_title +import server_list_explorer.ui.generated.resources.startup_restore_previous_state_description_short +import server_list_explorer.ui.generated.resources.startup_restore_previous_state_title +import server_list_explorer.ui.generated.resources.startup_single_instance_handling_description +import server_list_explorer.ui.generated.resources.startup_single_instance_handling_title +import server_list_explorer.ui.generated.resources.startup_when_computer_starts_section_title + +@Composable +internal fun StartupStep(state: SetupUiState) { + val setupStepSubtitle = + if (supportsStartupRegistration) { + t(Res.string.setup_startup_step_subtitle) + } else { + t(Res.string.setup_startup_step_subtitle_close_behavior) + } + + SetupStepContainer( + title = t(Res.string.setup_step_title_startup), + subtitle = setupStepSubtitle, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(StartupSectionSpacing), + ) { + if (isLowMemoryDevice) { + LowMemoryNoticeCard() + } + + Column(verticalArrangement = Arrangement.spacedBy(SectionSpacing)) { + if (supportsStartupRegistration) { + StartupModeSection( + selectedBehavior = state.startupSettings.computerStartupBehavior, + onBehaviorSelected = { newBehavior -> + state.startupSettings = + state.startupSettings.copy( + computerStartupBehavior = newBehavior, + ) + }, + ) + } + StartupToggleSection(state = state) + } + } + } +} + +@Composable +private fun StartupModeSection( + selectedBehavior: ComputerStartupBehavior, + onBehaviorSelected: (ComputerStartupBehavior) -> Unit, + modifier: Modifier = Modifier, +) = Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(SectionContentSpacing), +) { + SelectionContainer { + Text( + text = t(Res.string.startup_when_computer_starts_section_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = SectionTitleInset), + ) + } + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + ComputerStartupBehavior.entries.forEach { behavior -> + SegmentedButton( + selected = selectedBehavior == behavior, + onClick = { onBehaviorSelected(behavior) }, + shape = + SegmentedButtonDefaults.itemShape( + index = behavior.ordinal, + count = ComputerStartupBehavior.entries.size, + ), + modifier = Modifier.weight(SplitButtonWeight).pointerHoverIcon(PointerIcon.Hand), + ) { + Text(text = t(behavior.displayNameResource)) + } + } + } +} + +@Composable +private fun StartupToggleSection( + state: SetupUiState, + modifier: Modifier = Modifier, +) = Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(SectionContentSpacing), +) { + OnboardingItemSwitch( + title = t(Res.string.startup_minimize_to_tray_on_close_title), + isChecked = state.startupSettings.minimizeToSystemTrayOnClose, + onCheckedChange = { newValue -> + state.startupSettings = state.startupSettings.copy(minimizeToSystemTrayOnClose = newValue) + }, + ) + + HorizontalDivider( + modifier = Modifier.height(StartupToggleDividerThickness).padding(horizontal = StartupToggleDividerHorizontalInset), + color = MaterialTheme.colorScheme.outlineVariant, + ) + + OnboardingItemSwitch( + title = t(Res.string.startup_single_instance_handling_title), + description = t(Res.string.startup_single_instance_handling_description), + isChecked = state.startupSettings.singleInstanceHandling, + onCheckedChange = { newValue -> + state.startupSettings = state.startupSettings.copy(singleInstanceHandling = newValue) + }, + ) + + HorizontalDivider( + modifier = Modifier.height(StartupToggleDividerThickness).padding(horizontal = StartupToggleDividerHorizontalInset), + color = MaterialTheme.colorScheme.outlineVariant, + ) + + OnboardingItemSwitch( + title = t(Res.string.startup_restore_previous_state_title), + description = t(Res.string.startup_restore_previous_state_description_short), + isChecked = state.startupSettings.persistentSessionState, + onCheckedChange = { newValue -> + state.startupSettings = state.startupSettings.copy(persistentSessionState = newValue) + }, + ) +} + +@Composable +private fun LowMemoryNoticeCard(modifier: Modifier = Modifier) = + Surface( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.secondaryContainer, + tonalElevation = StartupSettingsCardElevation, + ) { + Row( + modifier = Modifier.padding(LowMemoryNoticePadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LowMemoryNoticeContentSpacing), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = t(Res.string.setup_startup_low_memory_icon_content_description), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(LowMemoryNoticeIconSize), + ) + Column(verticalArrangement = Arrangement.spacedBy(LowMemoryNoticeTextSpacing)) { + SelectionContainer { + Text( + text = t(Res.string.setup_startup_low_memory_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + ) + } + SelectionContainer { + Text( + text = t(Res.string.setup_startup_low_memory_message), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + } + +val supportsStartupRegistration = (OSUtils.isWindows || OSUtils.isDebian) && !OSUtils.isRunningOnBareJvm +private val isLowMemoryDevice by lazy { + OSUtils.totalPhysicalMemoryBytes in 1... + */ + +package com.spoiligaming.explorer.ui.screens.setup.widgets + +import androidx.compose.runtime.Composable +import com.spoiligaming.explorer.ui.screens.setup.components.OnboardingSettingTile +import com.spoiligaming.explorer.ui.widgets.ItemSwitch + +@Composable +internal fun OnboardingItemSwitch( + title: String, + description: String? = null, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) = OnboardingSettingTile( + title = title, + description = description, + trailingContent = { + ItemSwitch( + isChecked = isChecked, + onCheckedChange = onCheckedChange, + enabled = true, + ) + }, +) diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/systemtray/AppSystemTray.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/systemtray/AppSystemTray.kt new file mode 100644 index 0000000..485d296 --- /dev/null +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/systemtray/AppSystemTray.kt @@ -0,0 +1,291 @@ +/* + * This file is part of Server List Explorer. + * Copyright (C) 2026 SpoilerRules + * + * Server List Explorer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Server List Explorer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Server List Explorer. If not, see . + */ + +package com.spoiligaming.explorer.ui.systemtray + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.window.ApplicationScope +import com.kdroid.composetray.tray.api.Tray +import com.spoiligaming.explorer.ui.t +import com.spoiligaming.explorer.util.OSUtils +import io.github.oshai.kotlinlogging.KotlinLogging +import org.jetbrains.compose.resources.decodeToImageBitmap +import server_list_explorer.ui.generated.resources.Res +import server_list_explorer.ui.generated.resources.tray_content_description_icon +import server_list_explorer.ui.generated.resources.tray_item_exit +import server_list_explorer.ui.generated.resources.tray_item_hide +import server_list_explorer.ui.generated.resources.tray_item_open +import java.awt.SystemTray +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.ZipFile +import javax.imageio.ImageIO +import javax.swing.Icon +import javax.swing.UIManager +import javax.swing.filechooser.FileSystemView + +@Composable +internal fun ApplicationScope.AppSystemTray( + isSystemTrayFeatureEnabled: Boolean, + shouldMinimizeToSystemTrayOnClose: Boolean, + isWindowVisible: Boolean, + tooltip: String, + onHide: () -> Unit, + onOpen: () -> Unit, + onExit: () -> Unit, +) { + val shouldShowTray = + isSystemTrayFeatureEnabled && + (!isWindowVisible || shouldMinimizeToSystemTrayOnClose) + if (!shouldShowTray) return + + logger.info { "Attempting to render system tray" } + val isSystemTraySupported = SystemTray.isSupported() + if (!isSystemTraySupported) { + logger.warn { "System tray is not supported on this environment. Restoring main window." } + LaunchedEffect(isWindowVisible) { + onOpen() + } + return + } + + val trayIconBitmap = rememberTrayIconBitmap() + if (trayIconBitmap == null) { + logger.warn { "No JVM tray icon could be resolved. Restoring main window instead of rendering tray." } + LaunchedEffect(isWindowVisible) { + onOpen() + } + return + } + + val trayIconContentDescription = t(Res.string.tray_content_description_icon) + val trayHideItemLabel = t(Res.string.tray_item_hide) + val trayOpenItemLabel = t(Res.string.tray_item_open) + val trayExitItemLabel = t(Res.string.tray_item_exit) + + Tray( + iconContent = { + Image( + bitmap = trayIconBitmap, + contentDescription = trayIconContentDescription, + modifier = Modifier.fillMaxSize(), + ) + }, + tooltip = tooltip, + primaryAction = onOpen, + ) { + if (isWindowVisible) { + Item(trayHideItemLabel, onClick = onHide) + } else { + Item(trayOpenItemLabel, onClick = onOpen) + } + Item(trayExitItemLabel, onClick = onExit) + } +} + +@Composable +private fun rememberTrayIconBitmap() = + remember { + if (OSUtils.isWindows) { + loadWindowsLauncherIconBitmapOrNull() ?: loadJvmTrayIconBitmapOrNull() ?: loadJvmUiIconBitmapOrNull() + } else { + loadJvmTrayIconBitmapOrNull() ?: loadJvmUiIconBitmapOrNull() + } + } + +private fun loadWindowsLauncherIconBitmapOrNull() = + runCatching { + val launcher = + resolveWindowsLauncherPathOrNull()?.takeIf { it.exists() && it.isFile } + ?: return@runCatching null + + val shellIcon = FileSystemView.getFileSystemView().getSystemIcon(launcher) ?: return@runCatching null + iconToImageBitmapOrNull(shellIcon)?.also { + logger.debug { "Loaded tray icon from Windows launcher: ${launcher.absolutePath}" } + } + }.onFailure { e -> + logger.warn(e) { "Failed to load tray icon from Windows launcher executable." } + }.getOrNull() + +private fun resolveWindowsLauncherPathOrNull(): File? { + val jpackagePath = + System + .getProperty("jpackage.app-path") + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let(::File) + ?.takeIf { it.name.equals(WINDOWS_LAUNCHER_NAME, ignoreCase = true) } + + if (jpackagePath != null) return jpackagePath + + val processCommand = + ProcessHandle + .current() + .info() + .command() + .orElse(null) + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let(::File) + ?.takeIf { it.name.equals(WINDOWS_LAUNCHER_NAME, ignoreCase = true) } + + if (processCommand != null) return processCommand + + val userDir = System.getProperty("user.dir")?.takeIf { it.isNotBlank() }?.let(::File) ?: return null + val localLauncher = File(userDir, WINDOWS_LAUNCHER_NAME) + if (localLauncher.exists()) return localLauncher + + val appSubdirLauncher = File(userDir, "$APP_SUBDIRECTORY_NAME/$WINDOWS_LAUNCHER_NAME") + if (appSubdirLauncher.exists()) return appSubdirLauncher + + return null +} + +private fun loadJvmTrayIconBitmapOrNull(): ImageBitmap? { + val candidate = + JavaTrayIconResourceCandidates + .firstNotNullOfOrNull { path -> + loadJvmResourceBytesOrNull(path)?.let { bytes -> path to bytes } + } ?: return null + + val (resourcePath, iconBytes) = candidate + logger.debug { "Selected JVM tray icon resource candidate: $resourcePath" } + + return runCatching { + ByteArrayInputStream(iconBytes).use { stream -> + stream.readAllBytes().decodeToImageBitmap() + } + }.getOrElse { e -> + logger.warn(e) { "Failed to decode extracted JVM icon bytes into Bitmap" } + null + } +} + +private fun loadJvmUiIconBitmapOrNull() = + runCatching { + val icon = + JvmUiIconCandidates + .firstNotNullOfOrNull { key -> + UIManager.getIcon(key)?.takeIf { + it.iconWidth > 0 && it.iconHeight > 0 + } + } ?: return@runCatching null + + iconToImageBitmapOrNull(icon) + }.onFailure { e -> + logger.warn(e) { "Failed to load tray icon from JVM UI defaults." } + }.getOrNull() + +private fun loadJvmResourceBytesOrNull(resourcePath: String) = + runCatching { + // try module encapsulation break (java 9+) + ModuleLayer + .boot() + .findModule("java.desktop") + .orElse(null) + ?.getResourceAsStream(resourcePath) + ?.use { it.readAllBytes() } + ?.also { logger.debug { "Loaded JVM resource via ModuleLayer (java.desktop): $resourcePath" } } + + // try classloader (works if open or on classpath) + ?: ClassLoader + .getSystemResourceAsStream(resourcePath) + ?.use { it.readAllBytes() } + ?.also { logger.debug { "Loaded JVM resource via system ClassLoader: $resourcePath" } } + + // try direct JMOD extraction + ?: loadJvmResourceBytesFromJmodOrNull(resourcePath) + ?.also { logger.debug { "Loaded JVM resource via JMOD extraction: $resourcePath" } } + }.getOrNull() + +private fun loadJvmResourceBytesFromJmodOrNull(resourcePath: String) = + runCatching { + val javaHome = System.getProperty("java.home") ?: return@runCatching null + val javaHomeDirectory = File(javaHome) + + val jmodCandidates = + listOf( + File(javaHomeDirectory, "jmods/java.desktop.jmod"), // JDK standard + File(javaHomeDirectory.parentFile, "jmods/java.desktop.jmod"), // JRE inside JDK + ) + + jmodCandidates + .asSequence() + .filter { it.exists() && it.isFile } + .firstNotNullOfOrNull { jmodFile -> + runCatching { + ZipFile(jmodFile).use { zip -> + zip.getEntry("$JMOD_CLASSES_DIRECTORY/$resourcePath")?.let { entry -> + zip.getInputStream(entry).use { it.readAllBytes() } + } + } + }.getOrNull()?.also { + logger.debug { + "Loaded JVM resource from JMOD candidate: ${jmodFile.absolutePath} (resource: $resourcePath)" + } + } + } + }.getOrNull() + +private fun iconToImageBitmapOrNull(icon: Icon): ImageBitmap? { + val width = icon.iconWidth.coerceAtLeast(MIN_TRAY_ICON_SIZE) + val height = icon.iconHeight.coerceAtLeast(MIN_TRAY_ICON_SIZE) + val buffered = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val graphics = buffered.createGraphics() + + return try { + icon.paintIcon(null, graphics, ICON_RENDER_OFFSET, ICON_RENDER_OFFSET) + ByteArrayOutputStream().use { output -> + ImageIO.write(buffered, PNG_IMAGE_FORMAT, output) + output.toByteArray().decodeToImageBitmap() + } + } finally { + graphics.dispose() + } +} + +private const val APP_SUBDIRECTORY_NAME = "app" +private const val ICON_RENDER_OFFSET = 0 +private const val JMOD_CLASSES_DIRECTORY = "classes" +private const val MIN_TRAY_ICON_SIZE = 16 +private const val PNG_IMAGE_FORMAT = "png" +private const val WINDOWS_LAUNCHER_NAME = "ServerListExplorer.exe" + +private val JavaTrayIconResourceCandidates = + listOf( + "com/sun/java/swing/plaf/windows/icons/JavaCup32.png", + "com/sun/java/swing/plaf/windows/icons/JavaCup16.png", + ) + +private val JvmUiIconCandidates = + listOf( + "FileView.computerIcon", + "FileChooser.homeFolderIcon", + "OptionPane.informationIcon", + "Tree.closedIcon", + ) + +private val logger = KotlinLogging.logger {} diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/util/StartupTextResources.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/util/StartupTextResources.kt new file mode 100644 index 0000000..de6e17d --- /dev/null +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/util/StartupTextResources.kt @@ -0,0 +1,34 @@ +/* + * This file is part of Server List Explorer. + * Copyright (C) 2026 SpoilerRules + * + * Server List Explorer is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Server List Explorer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Server List Explorer. If not, see . + */ + +package com.spoiligaming.explorer.ui.util + +import com.spoiligaming.explorer.settings.model.ComputerStartupBehavior +import server_list_explorer.ui.generated.resources.Res +import server_list_explorer.ui.generated.resources.startup_behavior_do_not_start +import server_list_explorer.ui.generated.resources.startup_behavior_start_minimized_to_system_tray +import server_list_explorer.ui.generated.resources.startup_behavior_start_visible + +internal val ComputerStartupBehavior.displayNameResource + get() = + when (this) { + ComputerStartupBehavior.DoNotStart -> Res.string.startup_behavior_do_not_start + ComputerStartupBehavior.StartVisible -> Res.string.startup_behavior_start_visible + ComputerStartupBehavior.StartMinimizedToSystemTray -> + Res.string.startup_behavior_start_minimized_to_system_tray + } diff --git a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/window/WindowManager.kt b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/window/WindowManager.kt index e7ce98c..bd50a53 100644 --- a/ui/src/main/kotlin/com/spoiligaming/explorer/ui/window/WindowManager.kt +++ b/ui/src/main/kotlin/com/spoiligaming/explorer/ui/window/WindowManager.kt @@ -1,6 +1,6 @@ /* * This file is part of Server List Explorer. - * Copyright (C) 2025 SpoilerRules + * Copyright (C) 2025-2026 SpoilerRules * * Server List Explorer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -21,8 +21,10 @@ package com.spoiligaming.explorer.ui.window import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.unit.dp @@ -33,11 +35,17 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.spoiligaming.explorer.build.BuildConfig import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalPrefs +import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalStartupSettings import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.LocalWindowState import com.spoiligaming.explorer.ui.com.spoiligaming.explorer.ui.ProvideAppSettings +import com.spoiligaming.explorer.ui.systemtray.AppSystemTray +import com.spoiligaming.explorer.util.AppActivationSignal +import com.spoiligaming.explorer.util.FirstRunManager +import io.github.oshai.kotlinlogging.KotlinLogging import org.jetbrains.skiko.OS import org.jetbrains.skiko.hostOs import java.awt.Dimension +import java.awt.Frame internal object WindowManager { private const val WINDOW_TITLE = "Server List Explorer" @@ -60,42 +68,102 @@ internal object WindowManager { var isWindowShort by mutableStateOf(false) private set - fun launch(content: @Composable () -> Unit) = - application { - ProvideAppSettings { - val ws = LocalWindowState.current - val prefs = LocalPrefs.current - - LaunchedEffect(prefs.vsync) { - System.setProperty(SKIKO_VSYNC_PROPERTY, prefs.vsync.toString()) + fun launch( + isAutoStartupLaunch: Boolean, + content: @Composable () -> Unit, + ) = application { + var shouldFocusWindow by remember { mutableStateOf(false) } + + ProvideAppSettings { + val ws = LocalWindowState.current + val prefs = LocalPrefs.current + val startupSettings = LocalStartupSettings.current + val isFirstRun by FirstRunManager.isFirstRun.collectAsState() + var isWindowVisible by + remember(isAutoStartupLaunch, startupSettings.shouldStartMinimizedToSystemTray) { + mutableStateOf( + !(isAutoStartupLaunch && startupSettings.shouldStartMinimizedToSystemTray), + ) } - - val windowPlacement = - if (ws.isWindowMaximized) WindowPlacement.Maximized else WindowPlacement.Floating - - val windowState = - rememberWindowState( - placement = windowPlacement, - width = ws.width.dp, - height = ws.height.dp, - position = WindowPosition.Aligned(DefaultAlignment), + var shouldPrimeWindowComposition by + remember( + isAutoStartupLaunch, + startupSettings.isSystemTrayFeatureEnabled, + startupSettings.shouldStartMinimizedToSystemTray, + ) { + mutableStateOf( + isAutoStartupLaunch && + startupSettings.isSystemTrayFeatureEnabled && + startupSettings.shouldStartMinimizedToSystemTray, ) + } + + LaunchedEffect(prefs.vsync) { + System.setProperty(SKIKO_VSYNC_PROPERTY, prefs.vsync.toString()) + } - SideEffect { - isWindowCompact = - !ws.isWindowMaximized && - ws.currentWidth.dp < CompactWidthThreshold - isWindowShort = - !ws.isWindowMaximized && - ws.currentHeight.dp < ShortHeightThreshold + LaunchedEffect(Unit) { + AppActivationSignal.events.collect { + isWindowVisible = true + shouldFocusWindow = true } + } + val windowPlacement = + if (ws.isWindowMaximized) WindowPlacement.Maximized else WindowPlacement.Floating + + val windowState = + rememberWindowState( + placement = windowPlacement, + width = ws.width.dp, + height = ws.height.dp, + position = WindowPosition.Aligned(DefaultAlignment), + ) + + SideEffect { + isWindowCompact = + !ws.isWindowMaximized && + ws.currentWidth.dp < CompactWidthThreshold + isWindowShort = + !ws.isWindowMaximized && + ws.currentHeight.dp < ShortHeightThreshold + } + + AppSystemTray( + isSystemTrayFeatureEnabled = startupSettings.isSystemTrayFeatureEnabled, + shouldMinimizeToSystemTrayOnClose = startupSettings.minimizeToSystemTrayOnClose, + isWindowVisible = isWindowVisible, + tooltip = WINDOW_TITLE, + onHide = { + isWindowVisible = false + }, + onOpen = { + isWindowVisible = true + shouldFocusWindow = true + }, + onExit = { + exitApplication() + }, + ) + + val shouldKeepWindowComposed = + startupSettings.persistentSessionState && startupSettings.isSystemTrayFeatureEnabled + val shouldComposeWindow = + isWindowVisible || shouldKeepWindowComposed || shouldPrimeWindowComposition + + if (shouldComposeWindow) { val windowTitle = if (prefs.windowTitleShowBuildInfo) WINDOW_TITLE_WITH_BUILD else WINDOW_TITLE Window( - onCloseRequest = ::exitApplication, - visible = true, + onCloseRequest = { + if (startupSettings.minimizeToSystemTrayOnClose && isFirstRun.not()) { + isWindowVisible = false + } else { + exitApplication() + } + }, + visible = isWindowVisible, title = windowTitle, state = windowState, ) { @@ -112,8 +180,33 @@ internal object WindowManager { } } + LaunchedEffect(isWindowVisible) { + if (isWindowVisible) { + shouldPrimeWindowComposition = false + } + } + + LaunchedEffect(window, isWindowVisible, shouldFocusWindow) { + if (isWindowVisible && shouldFocusWindow) { + shouldFocusWindow = false + runCatching { + if (window.extendedState == Frame.ICONIFIED) { + window.extendedState = Frame.NORMAL + } + window.toFront() + window.requestFocus() + window.requestFocusInWindow() + }.onFailure { e -> + logger.error(e) { "Window activation request failed during tray restore." } + } + } + } + content() } } } + } } + +private val logger = KotlinLogging.logger {}