From 8181af1dbb8d2fc916cf7f394b972a476a324dd3 Mon Sep 17 00:00:00 2001 From: RavenLiao <47520028+RavenLiao@users.noreply.github.com> Date: Wed, 27 May 2026 21:18:58 +0800 Subject: [PATCH 1/3] fix(window): correct macOS controls side and Linux dialog close button (#252) --- .../nucleus/window/WindowControlArea.kt | 3 +++ .../nucleus/window/DialogTitleBar.Linux.kt | 18 +++---------- .../nucleus/window/DialogTitleBar.MacOS.kt | 2 +- .../nucleus/window/TitleBar.MacOS.kt | 2 +- .../nucleus/window/DialogTitleBar.Linux.kt | 27 ++++--------------- .../nucleus/window/DialogTitleBar.MacOS.kt | 2 +- .../nucleus/window/TitleBar.MacOS.kt | 2 +- 7 files changed, 16 insertions(+), 40 deletions(-) diff --git a/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/WindowControlArea.kt b/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/WindowControlArea.kt index 8704f5458..b96c05348 100644 --- a/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/WindowControlArea.kt +++ b/decorated-window-core/src/main/kotlin/io/github/kdroidfilter/nucleus/window/WindowControlArea.kt @@ -141,6 +141,8 @@ fun TitleBarScope.DialogCloseButton( ) { CompositionLocalProvider(LocalLayoutDirection provides LocalControlButtonsDirection.current) { val icons = linuxTitleBarIcons() + val layout = rememberLinuxButtonLayout() + val buttonAlignment = if (layout.controlsOnRight) Alignment.End else Alignment.Start val windowState = state.toDecoratedWindowState() val closeHover = if (windowState.isActive) icons.closeHoverFocused else icons.closeHover val closePressed = if (windowState.isActive) icons.closePressedFocused else icons.closePressed @@ -153,6 +155,7 @@ fun TitleBarScope.DialogCloseButton( iconPressed = closePressed, contentDescription = "Close", style = style, + alignment = buttonAlignment, isCloseButton = true, ) } diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt index 2674d6627..b62339664 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt @@ -1,6 +1,5 @@ package io.github.kdroidfilter.nucleus.window -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.ExperimentalComposeUiApi @@ -10,11 +9,9 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp import com.jetbrains.JBR -import io.github.kdroidfilter.nucleus.core.runtime.LinuxDesktopEnvironment import io.github.kdroidfilter.nucleus.window.styling.TitleBarStyle +import io.github.kdroidfilter.nucleus.window.utils.linux.rememberLinuxButtonLayout import java.awt.event.MouseEvent @OptIn(ExperimentalComposeUiApi::class) @@ -31,7 +28,8 @@ internal fun DecoratedDialogScope.LinuxDialogTitleBar( val linuxStyle = createLinuxTitleBarStyle(style) val dialogState = state val controlDir = controlButtonsDirection.resolve() - val controlsSide = if (controlDir == LayoutDirection.Rtl) WindowControlsSide.Start else WindowControlsSide.End + val controlsOnRight = rememberLinuxButtonLayout().controlsOnRight + val controlsSide = if (controlsOnRight) WindowControlsSide.End else WindowControlsSide.Start CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { DialogTitleBarImpl( @@ -48,15 +46,7 @@ internal fun DecoratedDialogScope.LinuxDialogTitleBar( style = linuxStyle, controlButtonsDirection = controlDir, layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> - val padding = - if (LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE) { - PaddingValues(end = 4.dp) - } else { - PaddingValues(0.dp) - } - padding - }, + applyTitleBar = { _, _ -> kdePaddingForButtonLayout() }, ) { _ -> DialogCloseButton(window, dialogState, linuxStyle) content(dialogState) diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt index 979ac9e26..3ce87af3f 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt @@ -29,7 +29,7 @@ internal fun DecoratedDialogScope.MacOSDialogTitleBar( val controlDir = controlButtonsDirection.resolve() val isRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (isRtl) WindowControlsSide.Start else WindowControlsSide.End + val controlsSide = if (isRtl) WindowControlsSide.End else WindowControlsSide.Start CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { DialogTitleBarImpl( diff --git a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt index 93bef633a..03abc2c0e 100644 --- a/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt +++ b/decorated-window-jbr/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt @@ -93,7 +93,7 @@ internal fun DecoratedWindowScope.MacOSTitleBar( val controlDir = controlButtonsDirection.resolve() val controlIsRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (controlIsRtl) WindowControlsSide.Start else WindowControlsSide.End + val controlsSide = if (controlIsRtl) WindowControlsSide.End else WindowControlsSide.Start CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { TitleBarImpl( diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt index 0bfb7bb1c..350595f90 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.Linux.kt @@ -1,6 +1,5 @@ package io.github.kdroidfilter.nucleus.window -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -13,10 +12,9 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import io.github.kdroidfilter.nucleus.core.runtime.LinuxDesktopEnvironment import io.github.kdroidfilter.nucleus.window.styling.TitleBarStyle import io.github.kdroidfilter.nucleus.window.utils.linux.JniLinuxWindowBridge +import io.github.kdroidfilter.nucleus.window.utils.linux.rememberLinuxButtonLayout import java.awt.MouseInfo @OptIn(ExperimentalComposeUiApi::class) @@ -31,7 +29,8 @@ internal fun DecoratedDialogScope.LinuxDialogTitleBar( content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, ) { val controlDir = controlButtonsDirection.resolve() - val controlsSide = if (controlDir == LayoutDirection.Rtl) WindowControlsSide.Start else WindowControlsSide.End + val controlsOnRight = rememberLinuxButtonLayout().controlsOnRight + val controlsSide = if (controlsOnRight) WindowControlsSide.End else WindowControlsSide.Start if (JniLinuxWindowBridge.isLoaded) { NativeLinuxDialogTitleBar( @@ -80,15 +79,7 @@ private fun DecoratedDialogScope.NativeLinuxDialogTitleBar( style = linuxStyle, controlButtonsDirection = controlButtonsDirection, layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> - val padding = - if (LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE) { - PaddingValues(end = 4.dp) - } else { - PaddingValues(0.dp) - } - padding - }, + applyTitleBar = { _, _ -> kdePaddingForButtonLayout() }, backgroundContent = { Spacer( modifier = @@ -152,15 +143,7 @@ private fun DecoratedDialogScope.FallbackLinuxDialogTitleBar( style = linuxStyle, controlButtonsDirection = controlButtonsDirection, layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> - val padding = - if (LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE) { - PaddingValues(end = 4.dp) - } else { - PaddingValues(0.dp) - } - padding - }, + applyTitleBar = { _, _ -> kdePaddingForButtonLayout() }, backgroundContent = { Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) }, diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt index 4d85faf93..1c72435c0 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/DialogTitleBar.MacOS.kt @@ -27,7 +27,7 @@ internal fun DecoratedDialogScope.MacOSDialogTitleBar( ) { val controlDir = controlButtonsDirection.resolve() val controlIsRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (controlIsRtl) WindowControlsSide.Start else WindowControlsSide.End + val controlsSide = if (controlIsRtl) WindowControlsSide.End else WindowControlsSide.Start DisposableEffect(window) { onDispose { diff --git a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt index e9c89d1f8..1c9439cf7 100644 --- a/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt +++ b/decorated-window-jni/src/main/kotlin/io/github/kdroidfilter/nucleus/window/TitleBar.MacOS.kt @@ -97,7 +97,7 @@ internal fun DecoratedWindowScope.MacOSTitleBar( // correct side. Uses the control buttons direction (decoupled from content). val controlDir = controlButtonsDirection.resolve() val controlIsRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (controlIsRtl) WindowControlsSide.Start else WindowControlsSide.End + val controlsSide = if (controlIsRtl) WindowControlsSide.End else WindowControlsSide.Start LaunchedEffect(window, controlIsRtl) { val ptr = JniMacWindowUtil.getWindowPtr(window) if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { From e24fe1d010549a4e174605e70c394bfeac3777ea Mon Sep 17 00:00:00 2001 From: Sean Proctor Date: Fri, 29 May 2026 10:08:26 -0400 Subject: [PATCH 2/3] feat(plugin): add per-OS jpackageVersion for jpackage app-image jpackage rejects SemVer pre-release/build metadata (e.g. "2.3.5-beta.7" fails with "invalid component [5-beta.7]"), but it is the only backend that builds the app-image (RawAppImage); all installer formats route through electron-builder, which accepts full SemVer. Add a per-OS `jpackageVersion` (windows/macOS/linux) used only for the jpackage app-image, falling back to `packageVersion` when unset. The value is passed to jpackage as-is. electron-builder formats continue to use `packageVersion` with the SemVer suffix intact. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../application/dsl/PlatformSettings.kt | 13 +++++++++ .../internal/configureJvmApplication.kt | 4 ++- .../application/internal/packageVersions.kt | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/dsl/PlatformSettings.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/dsl/PlatformSettings.kt index e2b4af265..5160df1e7 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/dsl/PlatformSettings.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/dsl/PlatformSettings.kt @@ -19,6 +19,19 @@ abstract class AbstractPlatformSettings { val iconFile: RegularFileProperty = objects.fileProperty() var packageVersion: String? = null + /** + * Version passed to jpackage when it builds the app-image (`--app-version`). + * + * jpackage enforces strict, platform-specific version rules — notably Windows requires + * `MAJOR.MINOR.BUILD` and rejects SemVer pre-release/build metadata such as `2.3.5-beta.7`. + * [packageVersion] is forwarded unchanged to electron-builder (which accepts full SemVer), so + * use this to give jpackage a compatible version without altering [packageVersion]. + * + * When unset, it defaults to [packageVersion]; the value is passed to jpackage as-is and + * jpackage reports an error if it is not compatible. + */ + var jpackageVersion: String? = null + internal val fileAssociations: MutableSet = mutableSetOf() @JvmOverloads diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureJvmApplication.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureJvmApplication.kt index 724914f86..f885fa16a 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureJvmApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureJvmApplication.kt @@ -708,7 +708,9 @@ private fun JvmApplicationContext.configurePackageTask( packageTask.packageDescription.set(executables.description) packageTask.packageCopyright.set(executables.copyright) packageTask.packageVendor.set(executables.vendor) - packageTask.packageVersion.set(packageVersionFor(packageTask.targetFormat)) + // jpackage app-image: use the jpackage-safe version. electron-builder formats keep the full + // SemVer (see configureElectronBuilderPackageTask, which uses packageVersionFor). + packageTask.packageVersion.set(jpackageVersionFor(packageTask.targetFormat)) } val dirSuffix = if (sandboxed) "-sandboxed" else "" diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/packageVersions.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/packageVersions.kt index e100fbfda..22114fe0f 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/packageVersions.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/packageVersions.kt @@ -17,6 +17,34 @@ internal fun JvmApplicationContext.packageVersionFor(targetFormat: TargetFormat) ?: "1.0.0" } +/** + * Version used when jpackage builds the app-image (`--app-version`). + * + * jpackage is the only [io.github.kdroidfilter.nucleus.desktop.application.dsl.PackagingBackend.JPACKAGE] + * step ([TargetFormat.RawAppImage]) and enforces strict platform version rules — notably Windows + * rejects SemVer pre-release/build metadata such as `2.3.5-beta.7`. All real installer formats run + * through electron-builder and keep the full SemVer via [packageVersionFor]. + * + * Resolves the platform's explicit + * [io.github.kdroidfilter.nucleus.desktop.application.dsl.AbstractPlatformSettings.jpackageVersion], + * falling back to the normal package version. The value is passed to jpackage as-is; jpackage + * reports an error if it is not compatible. + */ +internal fun JvmApplicationContext.jpackageVersionFor(targetFormat: TargetFormat): Provider = + project.provider { + app.nativeDistributions.jpackageVersionFor(targetFormat) + ?: app.nativeDistributions.packageVersionFor(targetFormat) + ?: project.version.toString().takeIf { it != "unspecified" } + ?: "1.0.0" + } + +private fun JvmApplicationDistributions.jpackageVersionFor(targetFormat: TargetFormat): String? = + when (targetFormat.targetOS) { + OS.Linux -> linux.jpackageVersion + OS.MacOS -> macOS.jpackageVersion + OS.Windows -> windows.jpackageVersion + } + @Suppress("CyclomaticComplexMethod") // Exhaustive when on TargetFormat enum private fun JvmApplicationDistributions.packageVersionFor(targetFormat: TargetFormat): String? { val formatSpecificVersion: String? = From 2b2b5dd9ebc47d1339ae78f16c2993de6508a03d Mon Sep 17 00:00:00 2001 From: Sean Proctor Date: Sun, 31 May 2026 05:41:30 -0400 Subject: [PATCH 3/3] remove jpackage configuration, just simplify --- .../application/dsl/PlatformSettings.kt | 13 ------------ .../internal/configureJvmApplication.kt | 3 +-- .../application/internal/packageVersions.kt | 21 ++++--------------- 3 files changed, 5 insertions(+), 32 deletions(-) diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/dsl/PlatformSettings.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/dsl/PlatformSettings.kt index 5160df1e7..e2b4af265 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/dsl/PlatformSettings.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/dsl/PlatformSettings.kt @@ -19,19 +19,6 @@ abstract class AbstractPlatformSettings { val iconFile: RegularFileProperty = objects.fileProperty() var packageVersion: String? = null - /** - * Version passed to jpackage when it builds the app-image (`--app-version`). - * - * jpackage enforces strict, platform-specific version rules — notably Windows requires - * `MAJOR.MINOR.BUILD` and rejects SemVer pre-release/build metadata such as `2.3.5-beta.7`. - * [packageVersion] is forwarded unchanged to electron-builder (which accepts full SemVer), so - * use this to give jpackage a compatible version without altering [packageVersion]. - * - * When unset, it defaults to [packageVersion]; the value is passed to jpackage as-is and - * jpackage reports an error if it is not compatible. - */ - var jpackageVersion: String? = null - internal val fileAssociations: MutableSet = mutableSetOf() @JvmOverloads diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureJvmApplication.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureJvmApplication.kt index f885fa16a..0543cfbda 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureJvmApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureJvmApplication.kt @@ -708,8 +708,7 @@ private fun JvmApplicationContext.configurePackageTask( packageTask.packageDescription.set(executables.description) packageTask.packageCopyright.set(executables.copyright) packageTask.packageVendor.set(executables.vendor) - // jpackage app-image: use the jpackage-safe version. electron-builder formats keep the full - // SemVer (see configureElectronBuilderPackageTask, which uses packageVersionFor). + // jpackage app-image: use the jpackage-safe version. packageTask.packageVersion.set(jpackageVersionFor(packageTask.targetFormat)) } diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/packageVersions.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/packageVersions.kt index 22114fe0f..d373b4063 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/packageVersions.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/packageVersions.kt @@ -24,26 +24,13 @@ internal fun JvmApplicationContext.packageVersionFor(targetFormat: TargetFormat) * step ([TargetFormat.RawAppImage]) and enforces strict platform version rules — notably Windows * rejects SemVer pre-release/build metadata such as `2.3.5-beta.7`. All real installer formats run * through electron-builder and keep the full SemVer via [packageVersionFor]. - * - * Resolves the platform's explicit - * [io.github.kdroidfilter.nucleus.desktop.application.dsl.AbstractPlatformSettings.jpackageVersion], - * falling back to the normal package version. The value is passed to jpackage as-is; jpackage - * reports an error if it is not compatible. */ internal fun JvmApplicationContext.jpackageVersionFor(targetFormat: TargetFormat): Provider = - project.provider { - app.nativeDistributions.jpackageVersionFor(targetFormat) - ?: app.nativeDistributions.packageVersionFor(targetFormat) - ?: project.version.toString().takeIf { it != "unspecified" } - ?: "1.0.0" - } + packageVersionFor(targetFormat).map { it.toJpackageVersion() } -private fun JvmApplicationDistributions.jpackageVersionFor(targetFormat: TargetFormat): String? = - when (targetFormat.targetOS) { - OS.Linux -> linux.jpackageVersion - OS.MacOS -> macOS.jpackageVersion - OS.Windows -> windows.jpackageVersion - } +// jpackage rejects SemVer pre-release/build metadata; keep only the MAJOR.MINOR.PATCH core. +private fun String.toJpackageVersion(): String = + substringBefore('-').substringBefore('+') @Suppress("CyclomaticComplexMethod") // Exhaustive when on TargetFormat enum private fun JvmApplicationDistributions.packageVersionFor(targetFormat: TargetFormat): String? {