From 69185f5224d78e4ad27343d56f424e165d390d16 Mon Sep 17 00:00:00 2001 From: Sean Proctor Date: Mon, 19 Oct 2026 12:21:29 -0400 Subject: [PATCH 1/3] fix(tao): send the desktop entry's name as the Wayland app_id Wayland compositors pair a toplevel with its launcher entry, and so show the app's icon in the dock, only when the xdg-shell app_id equals the .desktop file's basename. GTK 3 (Tao registers no GApplication id) sends g_get_prgname(), i.e. the launcher binary's name. For the JVM packages that is the jpackage launcher bin/, which the /opt// alias execs, while electron-builder names the entry after executableName = linux.packageName ?: packageName. Whenever linux.packageName differs from packageName the two never match: GNOME shows a generic icon and a separate dock item. Repro (GNOME 50, Wayland, Ubuntu): the Lab with linux { packageName = "nucleus-lab" }, WAYLAND_DEBUG=1: - deb (/opt/nucleus-lab/nucleus-lab): nucleus-lab.desktop, before xdg_toplevel.set_app_id("NucleusLab"), after ("nucleus-lab") - AppImage (APPIMAGE_EXTRACT_AND_RUN=1): nucleus-lab.desktop in $APPDIR, before "NucleusLab", after "nucleus-lab" - X11 (GDK_BACKEND=x11): WM_CLASS "NucleusLab", "NucleusLab" both before and after The plugin bakes linux.desktop.entry.id into nucleus-app.properties (NucleusApp.linuxDesktopEntryId), and TaoApplication hands it to the bridge, which calls g_set_prgname once GTK is up and only if its display is Wayland: on X11 the prgname is also WM_CLASS, left as it is. Flatpak sends FLATPAK_ID; snaps and native images keep GTK's default (a native image's binary is already named after its entry, graalvm.imageName). Co-Authored-By: Claude Opus 5.5 --- core-runtime/api/core-runtime.api | 1 + .../core/runtime/NucleusApp.kt | 13 ++++++ .../window/tao/TaoApplication.kt | 5 +++ .../window/tao/TaoWaylandAppId.kt | 41 +++++++++++++++++++ .../window/tao/ffi/NativeTaoBridge.kt | 8 ++++ .../src/main/native/src/event_loop.rs | 3 ++ .../src/main/native/src/platform/linux/mod.rs | 24 +++++++++++ .../src/main/native/src/window_jni.rs | 17 ++++++++ .../window/tao/TaoWaylandAppIdTest.kt | 41 +++++++++++++++++++ .../internal/configureJvmApplication.kt | 7 ++++ .../AbstractGenerateAppPropertiesTask.kt | 5 +++ 11 files changed, 165 insertions(+) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWaylandAppId.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoWaylandAppIdTest.kt diff --git a/core-runtime/api/core-runtime.api b/core-runtime/api/core-runtime.api index 14f8c4780..a6f31a3e3 100644 --- a/core-runtime/api/core-runtime.api +++ b/core-runtime/api/core-runtime.api @@ -114,6 +114,7 @@ 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 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..23d53ad61 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,7 @@ 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 RES_APP_ID = "app.id" private const val RES_APP_VERSION = "app.version" @@ -29,6 +30,7 @@ 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 val resourceProps: Properties? by lazy { loadResource() } @@ -87,6 +89,17 @@ 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) + } + /** `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 9fc2d1147..030c4dad0 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 @@ -1,6 +1,7 @@ package dev.nucleusframework.window.tao import dev.nucleusframework.core.runtime.NucleusUiThread +import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.core.runtime.WindowBackend import dev.nucleusframework.window.tao.dispatch.LifecycleMainDispatcherPriming import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher @@ -104,6 +105,10 @@ public object TaoApplication { // first `NavController.setGraph` call. LifecycleMainDispatcherPriming.primeWithCurrentThread() onLaunched = block + // Before the loop creates GTK's first window, which reads the app_id once. + if (Platform.Current == Platform.Linux) { + TaoWaylandAppId.current()?.let(NativeTaoBridge::nativeSetWaylandAppId) + } // Watch the loop from the outside (#643): a stall deadlocks this // thread, so nothing downstream of `nativeRunBlocking` — including // `rethrowPendingFatal` below — can ever report it. 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/ffi/NativeTaoBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt index b5e4ed638..218a8dc3b 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt @@ -204,6 +204,14 @@ internal object NativeTaoBridge { } } + /** + * 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 + * [nativeRunBlocking]. Ignored on X11, where the prgname is also `WM_CLASS`. + */ + @JvmStatic + external fun nativeSetWaylandAppId(appId: String) + /** Takes over the calling thread. Blocks until [nativeExit] is called. */ @JvmStatic external fun nativeRunBlocking(callback: EventCallback) 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 ab2537b9e..43b49d298 100644 --- a/decorated-window-tao/src/main/native/src/event_loop.rs +++ b/decorated-window-tao/src/main/native/src/event_loop.rs @@ -221,6 +221,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_wayland_app_id(); 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 85f348418..6267fa035 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 @@ -27,3 +27,27 @@ 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); + +/// Makes GTK send [WAYLAND_APP_ID] as each window's app_id. Call it after GTK is initialised and +/// before the first window is realized. +/// +/// GTK 3 (no `GApplication` id, as with Tao) sends `g_get_prgname()` as the app_id, which is +/// the launcher binary's name; compositors pair the window with the `.desktop` entry of that +/// name. Only applied on Wayland: on X11 the prgname is also the window's `WM_CLASS`, which must +/// stay as it is. +pub(crate) fn apply_wayland_app_id() { + use gtk::prelude::*; + + let Some(app_id) = WAYLAND_APP_ID.lock().ok().and_then(|id| id.clone()) else { + return; + }; + let wayland = + gtk::gdk::Display::default().is_some_and(|display| display.backend().is_wayland()); + if wayland { + glib::set_prgname(Some(app_id.as_str())); + } +} 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 128c96b40..c0fccee58 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,23 @@ 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_NativeTaoBridge_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_NativeTaoBridge_nativeCreateWindow( mut env: JNIEnv, 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/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 77c656de4..ef8527f22 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 @@ -302,6 +302,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" From 310551d01a8c7f33db2d1592e289423206e9a142 Mon Sep 17 00:00:00 2001 From: Sean Proctor Date: Mon, 19 Oct 2026 15:44:31 -0400 Subject: [PATCH 2/3] test(tao): declare TaoWaylandAppIdTest JVM-only in the battery drift guard It covers the pure app_id selection in TaoWaylandAppId.resolve, with the native-image case passed in as a parameter: no ComposeScene to run in the native-image battery, like the other pure-function tests listed there. Co-Authored-By: Claude Opus 5.5 --- .../nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt | 2 ++ 1 file changed, 2 insertions(+) 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 7bec363cd..5a421a1f2 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 @@ -167,6 +167,8 @@ 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", dev.nucleusframework.window.ChromeLogicTest::class.java to "unit tests for chrome helpers; no ComposeScene", NucleusWindowV2BridgeTest::class.java to From 11118ca6a99e989c9a2e9512d7b46dc902cb3221 Mon Sep 17 00:00:00 2001 From: Sean Proctor Date: Mon, 19 Oct 2026 22:25:53 -0400 Subject: [PATCH 3/3] fix(tao): give X11 windows the WM_CLASS of their desktop entry X11 desktops pair a window with its launcher entry by matching WM_CLASS against the entry's StartupWMClass. GTK 3 names Tao windows after the launcher binary (NucleusLab / NucleusLab), while the plugin writes StartupWMClass as linux.startupWMClass or the main class with dots replaced by dashes (the AWT XToolkit convention), so the two never agreed unless the app set startupWMClass to the binary's name. The plugin already bakes that value as startup.wm.class; NucleusApp now exposes it as linuxStartupWmClass, and the bridge sets both halves of WM_CLASS (g_set_prgname for the instance, gdk_set_program_class for the class) on an X11 display, next to the Wayland app_id. JVM packages and native images write the same entry, so both get it. Co-Authored-By: Claude Opus 5.5 --- core-runtime/api/core-runtime.api | 1 + .../core/runtime/NucleusApp.kt | 13 +++++++ .../window/tao/TaoApplication.kt | 4 +- .../window/tao/TaoX11WmClass.kt | 22 +++++++++++ .../window/tao/ffi/NativeTaoBridge.kt | 11 +++++- .../src/main/native/src/event_loop.rs | 2 +- .../src/main/native/src/platform/linux/mod.rs | 38 +++++++++++++------ .../src/main/native/src/window_jni.rs | 17 +++++++++ .../tao/TaoSceneTestBatteryDriftTest.kt | 2 + .../window/tao/TaoX11WmClassTest.kt | 31 +++++++++++++++ 10 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoX11WmClass.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoX11WmClassTest.kt diff --git a/core-runtime/api/core-runtime.api b/core-runtime/api/core-runtime.api index a6f31a3e3..4b0db8ef4 100644 --- a/core-runtime/api/core-runtime.api +++ b/core-runtime/api/core-runtime.api @@ -115,6 +115,7 @@ public final class dev/nucleusframework/core/runtime/NucleusApp { 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 23d53ad61..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 @@ -22,6 +22,7 @@ public object NucleusApp { 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" @@ -31,6 +32,7 @@ public object NucleusApp { 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() } @@ -100,6 +102,17 @@ public object NucleusApp { 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 030c4dad0..b1929b1dd 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 @@ -105,9 +105,11 @@ public object TaoApplication { // first `NavController.setGraph` call. LifecycleMainDispatcherPriming.primeWithCurrentThread() onLaunched = block - // Before the loop creates GTK's first window, which reads the app_id once. + // 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(NativeTaoBridge::nativeSetWaylandAppId) + TaoX11WmClass.current()?.let(NativeTaoBridge::nativeSetX11WmClass) } // Watch the loop from the outside (#643): a stall deadlocks this // thread, so nothing downstream of `nativeRunBlocking` — including 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/NativeTaoBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt index 218a8dc3b..23accfd75 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt @@ -207,11 +207,20 @@ internal object NativeTaoBridge { /** * 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 - * [nativeRunBlocking]. Ignored on X11, where the prgname is also `WM_CLASS`. + * [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 + * [nativeRunBlocking]. Ignored on Wayland. + */ + @JvmStatic + external fun nativeSetX11WmClass(wmClass: String) + /** Takes over the calling thread. Blocks until [nativeExit] is called. */ @JvmStatic external fun nativeRunBlocking(callback: EventCallback) 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 43b49d298..0d3bdc6d1 100644 --- a/decorated-window-tao/src/main/native/src/event_loop.rs +++ b/decorated-window-tao/src/main/native/src/event_loop.rs @@ -223,7 +223,7 @@ pub(crate) fn run_event_loop_blocking() { 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_wayland_app_id(); + 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 6267fa035..91bef5316 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 @@ -32,22 +32,36 @@ pub(crate) fn init_xlib_threads() { /// starts (`nativeSetWaylandAppId`). `None` keeps GTK's default. pub(crate) static WAYLAND_APP_ID: std::sync::Mutex> = std::sync::Mutex::new(None); -/// Makes GTK send [WAYLAND_APP_ID] as each window's app_id. Call it after GTK is initialised and -/// before the first window is realized. +/// 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: /// -/// GTK 3 (no `GApplication` id, as with Tao) sends `g_get_prgname()` as the app_id, which is -/// the launcher binary's name; compositors pair the window with the `.desktop` entry of that -/// name. Only applied on Wayland: on X11 the prgname is also the window's `WM_CLASS`, which must -/// stay as it is. -pub(crate) fn apply_wayland_app_id() { +/// - **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(app_id) = WAYLAND_APP_ID.lock().ok().and_then(|id| id.clone()) else { + let Some(display) = gtk::gdk::Display::default() else { return; }; - let wayland = - gtk::gdk::Display::default().is_some_and(|display| display.backend().is_wayland()); - if wayland { - glib::set_prgname(Some(app_id.as_str())); + 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 c0fccee58..fabe2361b 100644 --- a/decorated-window-tao/src/main/native/src/window_jni.rs +++ b/decorated-window-tao/src/main/native/src/window_jni.rs @@ -72,6 +72,23 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ let _ = (&mut env, app_id); } +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_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 5a421a1f2..e5cb3b345 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 @@ -169,6 +169,8 @@ class TaoSceneTestBatteryDriftTest { "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/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(" ")) + } +}