Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core-runtime/api/core-runtime.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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() }

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<snap>_<app>.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,
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions decorated-window-tao/src/main/native/src/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
38 changes: 38 additions & 0 deletions decorated-window-tao/src/main/native/src/platform/linux/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<String>> = 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<Option<String>> = 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);
}
}
}
34 changes: 34 additions & 0 deletions decorated-window-tao/src/main/native/src/window_jni.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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<String> = 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> = 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))
}
}
Original file line number Diff line number Diff line change
@@ -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(" "))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ abstract class AbstractGenerateAppPropertiesTask : DefaultTask() {
@get:Optional
abstract val startupWmClass: Property<String>

@get:Input
@get:Optional
abstract val linuxDesktopEntryId: Property<String>

@get:Input
@get:Optional
abstract val startupTaskId: Property<String>
Expand Down Expand Up @@ -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"
Expand Down
Loading