diff --git a/core-runtime/api/core-runtime.api b/core-runtime/api/core-runtime.api index e2295b712..e35c2f024 100644 --- a/core-runtime/api/core-runtime.api +++ b/core-runtime/api/core-runtime.api @@ -206,6 +206,8 @@ public final class dev/nucleusframework/core/runtime/NucleusApp { public static final fun getAppName ()Ljava/lang/String; public static final fun getAumid ()Ljava/lang/String; public static final fun getDescription ()Ljava/lang/String; + public static final fun getLinuxDesktopEntryId ()Ljava/lang/String; + public static final fun getLinuxStartupWmClass ()Ljava/lang/String; public static final fun getStartupTaskId ()Ljava/lang/String; public static final fun getVendor ()Ljava/lang/String; public static final fun getVersion ()Ljava/lang/String; diff --git a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NucleusApp.kt b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NucleusApp.kt index bd8b98e94..f1ae02852 100644 --- a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NucleusApp.kt +++ b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NucleusApp.kt @@ -21,6 +21,8 @@ public object NucleusApp { private const val PROP_APP_NAME = "nucleus.app.name" private const val PROP_APP_AUMID = "nucleus.app.aumid" private const val PROP_STARTUP_TASK_ID = "nucleus.app.startup.task.id" + private const val PROP_LINUX_DESKTOP_ENTRY_ID = "nucleus.app.linux.desktop.entry.id" + private const val PROP_LINUX_STARTUP_WM_CLASS = "nucleus.app.linux.startup.wm.class" private const val RES_APP_ID = "app.id" private const val RES_APP_VERSION = "app.version" @@ -29,6 +31,8 @@ public object NucleusApp { private const val RES_APP_NAME = "app.name" private const val RES_APP_AUMID = "app.aumid" private const val RES_STARTUP_TASK_ID = "startup.task.id" + private const val RES_LINUX_DESKTOP_ENTRY_ID = "linux.desktop.entry.id" + private const val RES_LINUX_STARTUP_WM_CLASS = "startup.wm.class" private val resourceProps: Properties? by lazy { loadResource() } @@ -87,6 +91,28 @@ public object NucleusApp { resolve(PROP_STARTUP_TASK_ID, RES_STARTUP_TASK_ID) } + /** + * The basename (without `.desktop`) of the freedesktop desktop entry the Linux packages + * install, e.g. `NucleusLab` for `NucleusLab.desktop`: the Linux `packageName`, else the + * root `packageName`. Wayland compositors pair a window with its launcher entry (and icon) + * by matching the window's `app_id` against this name. `null` if not configured. + */ + @JvmStatic + public val linuxDesktopEntryId: String? by lazy { + resolve(PROP_LINUX_DESKTOP_ENTRY_ID, RES_LINUX_DESKTOP_ENTRY_ID) + } + + /** + * The `StartupWMClass` the Linux packages write into their desktop entry: `linux.startupWMClass`, + * else the main class with dots replaced by dashes (the name AWT's XToolkit gives its windows). + * X11 desktops pair a window with its launcher entry (and icon) by matching the window's + * `WM_CLASS` against this value. `null` if not configured. + */ + @JvmStatic + public val linuxStartupWmClass: String? by lazy { + resolve(PROP_LINUX_STARTUP_WM_CLASS, RES_LINUX_STARTUP_WM_CLASS) + } + /** `true` if the Nucleus plugin injected metadata (via system property or classpath resource). */ @JvmStatic public val isConfigured: Boolean by lazy { diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt index 0ec2651d3..de2a380ba 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt @@ -7,6 +7,7 @@ import dev.nucleusframework.window.tao.dispatch.LifecycleMainDispatcherPriming import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher import dev.nucleusframework.window.tao.event.TaoDirectManipulationGesture import dev.nucleusframework.window.tao.ffi.NativeTaoBridge +import dev.nucleusframework.window.tao.ffi.NativeTaoLinuxIdentityBridge import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDirectManipulationBridge import kotlinx.coroutines.CoroutineExceptionHandler import java.util.concurrent.ConcurrentHashMap @@ -112,6 +113,12 @@ public object TaoApplication { LifecycleMainDispatcherPriming.primeWithCurrentThread() markJvmExitOnce() onLaunched = block + // Before the loop creates GTK's first window, which reads the app_id (Wayland) or + // WM_CLASS (X11) once. The bridge applies whichever matches the display it opens. + if (Platform.Current == Platform.Linux) { + TaoWaylandAppId.current()?.let(NativeTaoLinuxIdentityBridge::nativeSetWaylandAppId) + TaoX11WmClass.current()?.let(NativeTaoLinuxIdentityBridge::nativeSetX11WmClass) + } if (Platform.Current == Platform.Windows) { NativeTaoWindowsDirectManipulationBridge.nativeSetDirectManipulationEnabled( TaoDirectManipulationGesture.enabled, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWaylandAppId.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWaylandAppId.kt new file mode 100644 index 000000000..8571be890 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWaylandAppId.kt @@ -0,0 +1,41 @@ +package dev.nucleusframework.window.tao + +import dev.nucleusframework.core.runtime.ExecutableRuntime +import dev.nucleusframework.core.runtime.NucleusApp + +/** + * The `xdg_toplevel` app_id the Tao windows send on a Wayland display. Wayland compositors pair a + * window with its launcher entry, and so show the app's icon in the dock, only when the app_id + * equals the `.desktop` file's basename. + * + * GTK 3 sends the launcher binary's name, which for the JVM packages is the jpackage launcher + * named after the root `packageName`, while the entry is named after `linux.packageName`. + * They differ whenever `linux.packageName` is set to something else, so the plugin bakes the + * entry's name ([NucleusApp.linuxDesktopEntryId]) and the bridge sends that instead. + */ +internal object TaoWaylandAppId { + /** The app_id to send, or `null` to keep GTK's default (the launcher binary's name). */ + fun resolve( + desktopEntryId: String?, + env: (String) -> String?, + isNativeImage: Boolean, + ): String? { + // Flatpak installs the entry under the Flatpak app id. + env("FLATPAK_ID")?.takeIf { it.isNotBlank() }?.let { return it } + // Snap installs `_.desktop` and the compositor pairs snaps through their + // sandbox, not the app_id. + if (!env("SNAP_NAME").isNullOrBlank()) return null + // A native image's binary is named after its entry already (`graalvm.imageName`), which + // the baked id (the JVM packages' name) does not follow. + if (isNativeImage) return null + return desktopEntryId?.takeIf { it.isNotBlank() && '/' !in it } + } + + /** [resolve] for this process. */ + fun current(): String? = + resolve( + desktopEntryId = NucleusApp.linuxDesktopEntryId, + env = System::getenv, + isNativeImage = ExecutableRuntime.isGraalVmNativeImage, + ) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoX11WmClass.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoX11WmClass.kt new file mode 100644 index 000000000..2d2bf2dbe --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoX11WmClass.kt @@ -0,0 +1,22 @@ +package dev.nucleusframework.window.tao + +import dev.nucleusframework.core.runtime.NucleusApp + +/** + * The `WM_CLASS` the Tao windows carry on an X11 display. X11 desktops pair a window with its + * launcher entry, and so show the app's icon in the dock, by matching `WM_CLASS` against the + * entry's `StartupWMClass`. + * + * GTK 3 names the windows after the launcher binary (instance `warlock`, class `Warlock`), while + * the plugin writes `StartupWMClass` as `linux.startupWMClass` or else the main class with dots + * replaced by dashes, the name AWT's XToolkit uses. The two only agree when `startupWMClass` is + * set to the binary's name, so the bridge applies the baked value ([NucleusApp.linuxStartupWmClass]) + * instead. That holds for the JVM packages and native images alike: both write the same entry. + */ +internal object TaoX11WmClass { + /** The `WM_CLASS` to set, or `null` to keep GTK's default (the launcher binary's name). */ + fun resolve(startupWmClass: String?): String? = startupWmClass?.trim()?.takeIf { it.isNotEmpty() } + + /** [resolve] for this process. */ + fun current(): String? = resolve(NucleusApp.linuxStartupWmClass) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxIdentityBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxIdentityBridge.kt new file mode 100644 index 000000000..a3b164662 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxIdentityBridge.kt @@ -0,0 +1,27 @@ +package dev.nucleusframework.window.tao.ffi + +/** + * JNI bridge to the identity GTK gives every Linux window: the Wayland app_id and the X11 + * `WM_CLASS`. The symbols live in `nucleus_tao` (`window_jni.rs`), which [NativeTaoBridge] loads. + */ +internal object NativeTaoLinuxIdentityBridge { + /** Whether `nucleus_tao` is loaded; initialising this object loads it first. */ + val isLoaded: Boolean = NativeTaoBridge.isLoaded + + /** + * Linux: the app_id GTK sends for every window on a Wayland display (`g_set_prgname`, so + * the compositor pairs the windows with the `.desktop` entry of that name). Call before + * [NativeTaoBridge.nativeRunBlocking]. Ignored on X11, where [nativeSetX11WmClass] applies instead. + */ + @JvmStatic + external fun nativeSetWaylandAppId(appId: String) + + /** + * Linux: the `WM_CLASS` GTK gives every window on an X11 display, both its instance + * (`g_set_prgname`) and its class (`gdk_set_program_class`), so the desktop pairs the + * windows with the `.desktop` entry whose `StartupWMClass` it is. Call before + * [NativeTaoBridge.nativeRunBlocking]. Ignored on Wayland. + */ + @JvmStatic + external fun nativeSetX11WmClass(wmClass: String) +} diff --git a/decorated-window-tao/src/main/native/src/event_loop.rs b/decorated-window-tao/src/main/native/src/event_loop.rs index 5f82b6a92..35a15146a 100644 --- a/decorated-window-tao/src/main/native/src/event_loop.rs +++ b/decorated-window-tao/src/main/native/src/event_loop.rs @@ -244,6 +244,9 @@ pub(crate) fn run_event_loop_blocking() { builder.with_any_thread(true); } let mut event_loop = builder.build(); + // After `build()` (GTK is up, its display chosen), before any window exists. + #[cfg(target_os = "linux")] + crate::platform::linux::apply_window_identity(); set_event_loop_proxy(event_loop.create_proxy()); // Event-driven minimize/restore detection (see on_tao_minimized). diff --git a/decorated-window-tao/src/main/native/src/platform/linux/mod.rs b/decorated-window-tao/src/main/native/src/platform/linux/mod.rs index 3c131de17..59e96027d 100644 --- a/decorated-window-tao/src/main/native/src/platform/linux/mod.rs +++ b/decorated-window-tao/src/main/native/src/platform/linux/mod.rs @@ -29,3 +29,41 @@ pub(crate) fn init_xlib_threads() { } }); } + +/// The `xdg_toplevel` app_id to send on a Wayland display, set from the JVM before the loop +/// starts (`nativeSetWaylandAppId`). `None` keeps GTK's default. +pub(crate) static WAYLAND_APP_ID: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// The `WM_CLASS` to give windows on an X11 display, set from the JVM before the loop starts +/// (`nativeSetX11WmClass`). `None` keeps GTK's default. +pub(crate) static X11_WM_CLASS: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Makes GTK name the windows the way the desktop pairs them with their `.desktop` entry. Call it +/// after GTK is initialised (its display chosen) and before the first window is realized. +/// +/// GTK 3 (no `GApplication` id, as with Tao) derives both names from `g_get_prgname()`, the +/// launcher binary's name, which the entry need not match: +/// +/// - **Wayland:** the `xdg_toplevel` app_id is the prgname; compositors match it against the +/// entry's basename, so it is set to [WAYLAND_APP_ID]. +/// - **X11:** `WM_CLASS` is the prgname (instance) and `gdk_get_program_class()` (class, the +/// prgname capitalised by default); desktops match either against the entry's +/// `StartupWMClass`, so both are set to [X11_WM_CLASS]. +pub(crate) fn apply_window_identity() { + use gtk::prelude::*; + + let Some(display) = gtk::gdk::Display::default() else { + return; + }; + let backend = display.backend(); + if backend.is_wayland() { + if let Some(app_id) = WAYLAND_APP_ID.lock().ok().and_then(|id| id.clone()) { + glib::set_prgname(Some(app_id.as_str())); + } + } else if backend.is_x11() { + if let Some(wm_class) = X11_WM_CLASS.lock().ok().and_then(|class| class.clone()) { + glib::set_prgname(Some(wm_class.as_str())); + gtk::gdk::set_program_class(&wm_class); + } + } +} diff --git a/decorated-window-tao/src/main/native/src/window_jni.rs b/decorated-window-tao/src/main/native/src/window_jni.rs index aca3ed202..d3a7cdb64 100644 --- a/decorated-window-tao/src/main/native/src/window_jni.rs +++ b/decorated-window-tao/src/main/native/src/window_jni.rs @@ -55,6 +55,40 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ clear_event_loop_proxy(); } +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxIdentityBridge_nativeSetWaylandAppId( + mut env: JNIEnv, + _class: JClass, + app_id: JString, +) { + #[cfg(target_os = "linux")] + { + let app_id: Option = env.get_string(&app_id).ok().map(Into::into); + if let Ok(mut guard) = crate::platform::linux::WAYLAND_APP_ID.lock() { + *guard = app_id; + } + } + #[cfg(not(target_os = "linux"))] + let _ = (&mut env, app_id); +} + +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxIdentityBridge_nativeSetX11WmClass( + mut env: JNIEnv, + _class: JClass, + wm_class: JString, +) { + #[cfg(target_os = "linux")] + { + let wm_class: Option = env.get_string(&wm_class).ok().map(Into::into); + if let Ok(mut guard) = crate::platform::linux::X11_WM_CLASS.lock() { + *guard = wm_class; + } + } + #[cfg(not(target_os = "linux"))] + let _ = (&mut env, wm_class); +} + #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeCreateWindow( mut env: JNIEnv, diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt index d259c7872..ce224e8f6 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt @@ -177,6 +177,10 @@ class TaoSceneTestBatteryDriftTest { "pure-function buffer alignment; already covered via TaoScenePopupTest in the battery", XdgPortalParentTest::class.java to "pure-Kotlin portal parent / xdg_foreign handle formatting, no scene", + TaoWaylandAppIdTest::class.java to + "pure-function Wayland app_id selection; no ComposeScene", + TaoX11WmClassTest::class.java to + "pure-function X11 WM_CLASS selection; no ComposeScene", dev.nucleusframework.window.ChromeLogicTest::class.java to "unit tests for chrome helpers; no ComposeScene", NucleusWindowV2BridgeTest::class.java to diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWaylandAppIdTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWaylandAppIdTest.kt new file mode 100644 index 000000000..61ed369da --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWaylandAppIdTest.kt @@ -0,0 +1,41 @@ +package dev.nucleusframework.window.tao + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TaoWaylandAppIdTest { + private fun resolve( + desktopEntryId: String?, + env: Map = emptyMap(), + isNativeImage: Boolean = false, + ) = TaoWaylandAppId.resolve(desktopEntryId, env::get, isNativeImage) + + @Test + fun `a JVM package sends its desktop entry's name, not the launcher's`() { + // linux.packageName = "nucleus-lab", packageName = "NucleusLab": the entry is + // nucleus-lab.desktop while GTK would send the launcher's name, NucleusLab + assertEquals("nucleus-lab", resolve("nucleus-lab")) + } + + @Test + fun `nothing baked keeps GTK's default`() { + assertNull(resolve(null)) + assertNull(resolve("")) + assertNull(resolve("bad/id")) + } + + @Test + fun `a Flatpak sends its Flatpak app id`() { + assertEquals( + "dev.nucleusframework.Lab", + resolve("nucleus-lab", mapOf("FLATPAK_ID" to "dev.nucleusframework.Lab")), + ) + } + + @Test + fun `a snap and a native image keep GTK's default`() { + assertNull(resolve("nucleus-lab", mapOf("SNAP_NAME" to "nucleus-lab"))) + assertNull(resolve("nucleus-lab", isNativeImage = true)) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoX11WmClassTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoX11WmClassTest.kt new file mode 100644 index 000000000..7d109f675 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoX11WmClassTest.kt @@ -0,0 +1,31 @@ +package dev.nucleusframework.window.tao + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TaoX11WmClassTest { + @Test + fun `the windows take the StartupWMClass the packager wrote`() { + // Derived from mainClass = "dev.nucleusframework.lab.app.MainKt": the Lab's entry says + // StartupWMClass=dev-nucleusframework-lab-app-MainKt while GTK would name the window + // after the launcher, NucleusLab. + assertEquals( + "dev-nucleusframework-lab-app-MainKt", + TaoX11WmClass.resolve("dev-nucleusframework-lab-app-MainKt"), + ) + } + + @Test + fun `an explicit startupWMClass is kept as written`() { + assertEquals("warlock", TaoX11WmClass.resolve("warlock")) + assertEquals("warlock", TaoX11WmClass.resolve(" warlock ")) + } + + @Test + fun `nothing baked keeps GTK's default`() { + assertNull(TaoX11WmClass.resolve(null)) + assertNull(TaoX11WmClass.resolve("")) + assertNull(TaoX11WmClass.resolve(" ")) + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt index c0a0a35ef..e655ad55f 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt @@ -303,6 +303,13 @@ private fun JvmApplicationContext.registerGenerateAppProperties() { ?.takeIf { it.isNotBlank() } ?: app.mainClass?.replace('.', '-') wmClass?.let { startupWmClass.set(it) } + // The Linux packages name their .desktop file after the executable + // (`linux.packageName ?: packageName`, see `executableName` below), while the + // jpackage launcher GTK takes its Wayland app_id from is named after the root + // packageName. Baked so the Tao backend can send the entry's name as the app_id. + (app.nativeDistributions.linux.packageName ?: app.nativeDistributions.packageName) + ?.takeIf { it.isNotBlank() } + ?.let { linuxDesktopEntryId.set(it) } // Resolve AppX StartupTask TaskId (MSIX auto-launch injection). // electron-builder hardcodes TaskId="SlackStartup" in the injected manifest // (legacy from its Slack origins) — this is the TaskId the runtime must use diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractGenerateAppPropertiesTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractGenerateAppPropertiesTask.kt index c4a65e568..95b97b35a 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractGenerateAppPropertiesTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractGenerateAppPropertiesTask.kt @@ -47,6 +47,10 @@ abstract class AbstractGenerateAppPropertiesTask : DefaultTask() { @get:Optional abstract val startupWmClass: Property + @get:Input + @get:Optional + abstract val linuxDesktopEntryId: Property + @get:Input @get:Optional abstract val startupTaskId: Property @@ -80,6 +84,7 @@ abstract class AbstractGenerateAppPropertiesTask : DefaultTask() { appName.orNull?.let { props["app.name"] = it } appAumid.orNull?.let { props["app.aumid"] = it } startupWmClass.orNull?.let { props["startup.wm.class"] = it } + linuxDesktopEntryId.orNull?.let { props["linux.desktop.entry.id"] = it } startupTaskId.orNull?.let { props["startup.task.id"] = it } if (idleGc.getOrElse(false)) props[NUCLEUS_IDLE_GC_RESOURCE_KEY] = "true" if (partialRedraw.getOrElse(false)) props[NUCLEUS_PARTIAL_REDRAW_RESOURCE_KEY] = "true"