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
4 changes: 4 additions & 0 deletions .github/workflows/build-natives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ jobs:
working-directory: src/native/linux
run: bash run_pixmap_test.sh

- name: Primary action controls ItemIsMenu
working-directory: src/native/linux
run: bash run_primary_action_test.sh

- name: JNI strings are standard UTF-8 (issue #441)
working-directory: src/native/shared
run: bash run_jni_utf8_test.sh
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pr-build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,6 @@ jobs:

- name: Build
run: ./gradlew build --continue

- name: Linux primary action updates reach the tray host
run: dbus-run-session -- env COMPOSE_TRAY_DBUS_TEST=1 ./gradlew :jvmTest --tests '*LinuxPrimaryActionTest' --rerun-tasks
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ internal class LinuxTrayManager(
// Native handle
private var trayHandle: Long = 0L

private val clickCallback =
JniRunnable {
val action = lock.withLock { onLeftClick } ?: return@JniRunnable
try {
val xy = IntArray(2)
native.nativeGetLastClickXY(trayHandle, xy)
TrayClickTracker.recordClick(xy[0], xy[1])
} catch (_: Throwable) {
}
action()
}

private fun syncClickCallback() {
native.nativeSetClickCallback(trayHandle, if (onLeftClick != null) clickCallback else null)
}

// Thread / lifecycle
private var shutdownHook: Thread? = null
private var loopThread: Thread? = null
Expand Down Expand Up @@ -110,7 +126,9 @@ internal class LinuxTrayManager(
tooltipChanged = (tooltip != newTooltip)
iconPath = newIconPath
tooltip = newTooltip
val clickPresenceChanged = (onLeftClick == null) != (newOnLeftClick == null)
onLeftClick = newOnLeftClick
if (clickPresenceChanged && trayHandle != 0L) syncClickCallback()
onMenuOpened = newOnMenuOpened
if (newMenuItems != null) {
menuItems.clear()
Expand Down Expand Up @@ -164,18 +182,7 @@ internal class LinuxTrayManager(
runCatching { native.nativeSetTitle(trayHandle, tooltip) }

// Set click callback
native.nativeSetClickCallback(
trayHandle,
JniRunnable {
try {
val xy = IntArray(2)
native.nativeGetLastClickXY(trayHandle, xy)
TrayClickTracker.recordClick(xy[0], xy[1])
} catch (_: Throwable) {
}
onLeftClick?.invoke()
},
)
lock.withLock { syncClickCallback() }

// Set menu-opened callback
native.nativeSetMenuOpenedCallback(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package dev.nucleusframework.composenativetray.lib.linux

import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* Run on Linux with busctl installed:
* `dbus-run-session -- env COMPOSE_TRAY_DBUS_TEST=1 ./gradlew :jvmTest --tests '*LinuxPrimaryActionTest'`
*/
class LinuxPrimaryActionTest {
@Test
fun `manager synchronizes primary action presence and replacement`() {
if (System.getenv("COMPOSE_TRAY_DBUS_TEST") != "1") return
val calls = AtomicInteger()
val tray = LinuxTrayManager("primary-action-test", "", "test")
tray.startTray()
try {
awaitProperty("b true")
activate()
assertEquals(0, calls.get())

tray.update("", "test", { calls.incrementAndGet() }, null)
awaitProperty("b false")
activate()
awaitCalls(calls, 1)

tray.update("", "test", { calls.addAndGet(10) }, null)
activate()
awaitCalls(calls, 11)

tray.update("", "test", null, null)
awaitProperty("b true")
activate()
assertEquals(11, calls.get())
} finally {
tray.stopTray()
}

val initialActionTray =
LinuxTrayManager("initial-action-test", "", "test", { calls.incrementAndGet() })
initialActionTray.startTray()
try {
awaitProperty("b false")
activate()
awaitCalls(calls, 12)
} finally {
initialActionTray.stopTray()
}
}

private fun awaitCalls(
calls: AtomicInteger,
expected: Int,
) {
repeat(200) {
if (calls.get() == expected) return
Thread.sleep(10)
}
assertEquals(expected, calls.get())
}

private fun awaitProperty(expected: String) {
var output = ""
repeat(200) {
output = busctl("get-property", service, path, iface, "ItemIsMenu")
if (output == expected) return
Thread.sleep(10)
}
assertEquals(expected, output)
}

private fun activate() {
assertEquals("", busctl("call", service, path, iface, "Activate", "ii", "12", "34"))
// Activate's D-Bus reply can arrive before the queued JVM callback.
Thread.sleep(50)
}

private fun busctl(vararg args: String): String {
val process =
ProcessBuilder(listOf("busctl", "--user", "--timeout=2") + args).redirectErrorStream(true).start()
assertTrue(process.waitFor(5, TimeUnit.SECONDS), "busctl timed out")
return process.inputStream.bufferedReader().readText().trim()
}

private val service = "org.kde.StatusNotifierItem-${ProcessHandle.current().pid()}-1"
private val path = "/StatusNotifierItem"
private val iface = "org.kde.StatusNotifierItem"
}
18 changes: 16 additions & 2 deletions src/native/linux/jni_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include <string.h>
#include <stdint.h>
#include <dlfcn.h>
#include <pthread.h>

#include "jni_utf8.h"
#include "sni.h"
Expand Down Expand Up @@ -53,6 +54,7 @@ typedef struct CallbackEntry {
} CallbackEntry;

static CallbackEntry *g_clickCallback = NULL;
static pthread_mutex_t g_clickCallbackLock = PTHREAD_MUTEX_INITIALIZER;
static CallbackEntry *g_rclickCallback = NULL;
static CallbackEntry *g_menuCallbacks = NULL;
static CallbackEntry *g_menuOpenedCallback = NULL;
Expand Down Expand Up @@ -132,8 +134,16 @@ static void invokeRunnable(jobject runnable) {
static void click_trampoline(int32_t x, int32_t y, void *userdata) {
(void)x; (void)y;
uintptr_t key = (uintptr_t)userdata;
jobject runnable = findCallback(g_clickCallback, key);
if (runnable) invokeRunnable(runnable);
JNIEnv *env = getJNIEnv();
if (!env) return;
pthread_mutex_lock(&g_clickCallbackLock);
jobject globalRef = findCallback(g_clickCallback, key);
jobject runnable = globalRef ? (*env)->NewLocalRef(env, globalRef) : NULL;
pthread_mutex_unlock(&g_clickCallbackLock);
if (runnable) {
invokeRunnable(runnable);
(*env)->DeleteLocalRef(env, runnable);
}
}

static void rclick_trampoline(int32_t x, int32_t y, void *userdata) {
Expand Down Expand Up @@ -215,7 +225,9 @@ Java_dev_nucleusframework_composenativetray_lib_linux_LinuxNativeBridge_nativeDe

/* Clean up all callbacks for this tray */
uintptr_t key = (uintptr_t)tray;
pthread_mutex_lock(&g_clickCallbackLock);
storeCallback(&g_clickCallback, key, env, NULL);
pthread_mutex_unlock(&g_clickCallbackLock);
storeCallback(&g_rclickCallback, key, env, NULL);
storeCallback(&g_menuOpenedCallback, key, env, NULL);
/* Menu callbacks are keyed by item id, clear all */
Expand Down Expand Up @@ -274,7 +286,9 @@ Java_dev_nucleusframework_composenativetray_lib_linux_LinuxNativeBridge_nativeSe
sni_tray *tray = (sni_tray *)(uintptr_t)handle;
if (!tray) return;
uintptr_t key = (uintptr_t)tray;
pthread_mutex_lock(&g_clickCallbackLock);
storeCallback(&g_clickCallback, key, env, callback);
pthread_mutex_unlock(&g_clickCallbackLock);
sni_tray_set_click_callback(tray,
callback ? click_trampoline : NULL,
(void *)key);
Expand Down
12 changes: 12 additions & 0 deletions src/native/linux/run_primary_action_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/bash
# Use a private session bus so the test does not register with the user's desktop.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN="$(mktemp)"
trap 'rm -f "$BIN"' EXIT

gcc -O2 -g -Wall -Wextra -Werror -Wno-unused-parameter \
$(pkg-config --cflags libsystemd) \
"$SCRIPT_DIR/sni.c" "$SCRIPT_DIR/test_sni_primary_action.c" \
$(pkg-config --libs libsystemd) -lpthread -lm -ldl -o "$BIN"
dbus-run-session -- "$BIN"
20 changes: 17 additions & 3 deletions src/native/linux/sni.c
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,14 @@ static void flush_callbacks(sni_tray *tray) {
for (int i = 0; i < tray->pending_count; i++) {
pending_cb *c = &tray->pending[i];
switch (c->type) {
case PCB_CLICK:
if (tray->on_click) tray->on_click(c->x, c->y, tray->on_click_data);
case PCB_CLICK: {
bus_lock(tray);
sni_click_cb callback = tray->on_click;
void *userdata = tray->on_click_data;
bus_unlock(tray);
if (callback) callback(c->x, c->y, userdata);
break;
}
case PCB_RCLICK:
if (tray->on_rclick) tray->on_rclick(c->x, c->y, tray->on_rclick_data);
break;
Expand Down Expand Up @@ -510,7 +515,7 @@ static int sni_get_property(sd_bus *bus, const char *path, const char *interface
if (strcmp(property, "Menu") == 0)
return sd_bus_message_append(reply, "o", tray->current_menu_path);
if (strcmp(property, "ItemIsMenu") == 0)
return sd_bus_message_append(reply, "b", 1);
return sd_bus_message_append(reply, "b", tray->on_click == NULL);
if (strcmp(property, "IconName") == 0)
return sd_bus_message_append(reply, "s", "");
if (strcmp(property, "IconPixmap") == 0)
Expand Down Expand Up @@ -1285,8 +1290,17 @@ void sni_tray_set_tooltip(sni_tray *tray, const char *tooltip) {

void sni_tray_set_click_callback(sni_tray *tray, sni_click_cb cb, void *userdata) {
if (!tray) return;
bus_lock(tray);
int changed = (tray->on_click == NULL) != (cb == NULL);
tray->on_click = cb;
tray->on_click_data = userdata;
if (changed) {
emit_sni_properties_changed(tray, "ItemIsMenu");
/* Plasma refreshes its cached ItemIsMenu via SNI signals, rather than
* PropertiesChanged. NewTitle triggers GetAll without rebuilding the menu. */
emit_new_title(tray);
}
bus_unlock(tray);
}

void sni_tray_set_rclick_callback(sni_tray *tray, sni_click_cb cb, void *userdata) {
Expand Down
2 changes: 2 additions & 0 deletions src/native/linux/sni.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ void sni_tray_set_tooltip(sni_tray *tray, const char *tooltip);

/* ── Click callbacks ───────────────────────────────────────────────── */

/* A non-NULL primary action sets ItemIsMenu=false; NULL restores menu-only
* activation. Changes are announced to the tray host while running. */
void sni_tray_set_click_callback(sni_tray *tray, sni_click_cb cb, void *userdata);
void sni_tray_set_rclick_callback(sni_tray *tray, sni_click_cb cb, void *userdata);
void sni_tray_set_menu_callback(sni_tray *tray, sni_menu_item_cb cb, void *userdata);
Expand Down
Loading
Loading