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
631 changes: 631 additions & 0 deletions maclib/MacTrayBridge.m

Large diffs are not rendered by default.

80 changes: 55 additions & 25 deletions maclib/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,69 @@
# Exit on error
set -e

echo "Building MacTray library..."
echo "Building MacTray library (Swift + JNI bridge)..."

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OUTPUT_DIR="${NATIVE_LIBS_OUTPUT_DIR:-$SCRIPT_DIR/../src/commonMain/resources}"
echo "Output dir for mac is: $OUTPUT_DIR"

echo "Building for ARM64 (Apple Silicon)..."
# Detect JAVA_HOME
if [ -z "$JAVA_HOME" ]; then
JAVA_HOME=$(/usr/libexec/java_home 2>/dev/null || true)
fi
if [ -z "$JAVA_HOME" ] || [ ! -d "$JAVA_HOME" ]; then
echo "ERROR: JAVA_HOME not found. Install a JDK or set JAVA_HOME."
exit 1
fi
echo "Using JAVA_HOME: $JAVA_HOME"

JNI_INCLUDE="$JAVA_HOME/include"
JNI_INCLUDE_DARWIN="$JAVA_HOME/include/darwin"

if [ ! -f "$JNI_INCLUDE/jni.h" ]; then
echo "ERROR: jni.h not found at $JNI_INCLUDE/jni.h"
exit 1
fi

mkdir -p "$OUTPUT_DIR/darwin-aarch64"
mkdir -p "$OUTPUT_DIR/darwin-x86-64"

swiftc -emit-library -o "$OUTPUT_DIR/darwin-aarch64/libMacTray.dylib" \
-module-name MacTray \
-swift-version 5 \
-target arm64-apple-macosx11.0 \
-O -whole-module-optimization \
-framework Foundation \
-framework Cocoa \
-Xlinker -rpath -Xlinker @executable_path/../Frameworks \
-Xlinker -rpath -Xlinker @loader_path/Frameworks \
tray.swift

echo "Building for x86_64 (Intel)..."

swiftc -emit-library -o "$OUTPUT_DIR/darwin-x86-64/libMacTray.dylib" \
-module-name MacTray \
-swift-version 5 \
-target x86_64-apple-macosx11.0 \
-O -whole-module-optimization \
-framework Foundation \
-framework Cocoa \
-Xlinker -rpath -Xlinker @executable_path/../Frameworks \
-Xlinker -rpath -Xlinker @loader_path/Frameworks \
tray.swift
build_arch() {
local ARCH=$1
local TARGET=$2
local OUT_DIR=$3

echo "Building for $ARCH..."

# 1. Compile the JNI bridge (Objective-C) -> bridge.o
clang -c -o "$SCRIPT_DIR/bridge_${ARCH}.o" \
-arch "$ARCH" \
-mmacosx-version-min=11.0 \
-I "$JNI_INCLUDE" \
-I "$JNI_INCLUDE_DARWIN" \
-I "$SCRIPT_DIR" \
-fobjc-arc \
"$SCRIPT_DIR/MacTrayBridge.m"

# 2. Compile Swift + link with the bridge object -> dylib
swiftc -emit-library -o "$OUT_DIR/libMacTray.dylib" \
-module-name MacTray \
-swift-version 5 \
-target "$TARGET" \
-O -whole-module-optimization \
-framework Foundation \
-framework Cocoa \
-framework ApplicationServices \
-Xlinker -rpath -Xlinker @executable_path/../Frameworks \
-Xlinker -rpath -Xlinker @loader_path/Frameworks \
"$SCRIPT_DIR/tray.swift" \
"$SCRIPT_DIR/bridge_${ARCH}.o"

# Clean up intermediate object
rm -f "$SCRIPT_DIR/bridge_${ARCH}.o"
}

build_arch "arm64" "arm64-apple-macosx11.0" "$OUTPUT_DIR/darwin-aarch64"
build_arch "x86_64" "x86_64-apple-macosx11.0" "$OUTPUT_DIR/darwin-x86-64"

echo "Build completed successfully."
16 changes: 16 additions & 0 deletions maclib/tray.h
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ TRAY_API const char *tray_get_status_item_region_for(struct tray *tray);
/* macOS: pre-rendered appearance icons for instant light/dark switching */
TRAY_API void tray_set_icons_for_appearance(struct tray *tray, const char *light_icon, const char *dark_icon);

/* macOS: space behavior for all windows */
TRAY_API void tray_set_windows_move_to_active_space(void);

/* macOS: dock visibility */
TRAY_API int tray_show_in_dock(void);
TRAY_API int tray_hide_from_dock(void);

/* macOS: floating window / Space management */
TRAY_API int tray_is_floating_window_on_active_space(void);
TRAY_API int tray_bring_floating_window_to_front(void);
TRAY_API int tray_set_move_to_active_space_for_view(void *nsViewPtr);
TRAY_API int tray_is_on_active_space_for_view(void *nsViewPtr);

/* macOS: mouse button state */
TRAY_API int tray_get_mouse_button_state(int button);

#ifdef __cplusplus
} /* extern "C" */
#endif
Expand Down
91 changes: 91 additions & 0 deletions maclib/tray.swift
Original file line number Diff line number Diff line change
Expand Up @@ -563,4 +563,95 @@ public func tray_set_windows_move_to_active_space() {
for window in NSApp.windows {
window.collectionBehavior.insert(.moveToActiveSpace)
}
}

// MARK: - Dock visibility

@_cdecl("tray_show_in_dock")
public func tray_show_in_dock() -> Int32 {
let doWork = { () -> Int32 in
NSApp.setActivationPolicy(.regular)
return 0
}
if Thread.isMainThread { return doWork() }
return DispatchQueue.main.sync { doWork() }
}

@_cdecl("tray_hide_from_dock")
public func tray_hide_from_dock() -> Int32 {
let doWork = { () -> Int32 in
NSApp.setActivationPolicy(.accessory)
return 0
}
if Thread.isMainThread { return doWork() }
return DispatchQueue.main.sync { doWork() }
}

// MARK: - Floating window / Space management

@_cdecl("tray_is_floating_window_on_active_space")
public func tray_is_floating_window_on_active_space() -> Int32 {
let doWork = { () -> Int32 in
for window in NSApp.windows {
if window.level.rawValue > 0 {
return window.isOnActiveSpace ? 1 : 0
}
}
return 1 // No floating window found, assume on active Space
}
if Thread.isMainThread { return doWork() }
return DispatchQueue.main.sync { doWork() }
}

@_cdecl("tray_bring_floating_window_to_front")
public func tray_bring_floating_window_to_front() -> Int32 {
let doWork = { () -> Int32 in
NSApp.activate(ignoringOtherApps: true)
for window in NSApp.windows {
if window.level.rawValue > 0 {
window.makeKeyAndOrderFront(nil)
return 0
}
}
return -1 // No floating window found
}
if Thread.isMainThread { return doWork() }
return DispatchQueue.main.sync { doWork() }
}

@_cdecl("tray_set_move_to_active_space_for_view")
public func tray_set_move_to_active_space_for_view(_ nsViewPtr: UnsafeMutableRawPointer?) -> Int32 {
let doWork = { () -> Int32 in
guard let viewPtr = nsViewPtr else { return -1 }
let nsView = Unmanaged<NSView>.fromOpaque(viewPtr).takeUnretainedValue()
guard let nsWindow = nsView.window else { return -1 }
var behavior = nsWindow.collectionBehavior
behavior.remove(.canJoinAllSpaces)
behavior.insert(.moveToActiveSpace)
nsWindow.collectionBehavior = behavior
return 0
}
if Thread.isMainThread { return doWork() }
return DispatchQueue.main.sync { doWork() }
}

@_cdecl("tray_is_on_active_space_for_view")
public func tray_is_on_active_space_for_view(_ nsViewPtr: UnsafeMutableRawPointer?) -> Int32 {
let doWork = { () -> Int32 in
guard let viewPtr = nsViewPtr else { return 1 } // fail-open
let nsView = Unmanaged<NSView>.fromOpaque(viewPtr).takeUnretainedValue()
guard let nsWindow = nsView.window else { return 1 }
return nsWindow.isOnActiveSpace ? 1 : 0
}
if Thread.isMainThread { return doWork() }
return DispatchQueue.main.sync { doWork() }
}

// MARK: - Mouse button state

@_cdecl("tray_get_mouse_button_state")
public func tray_get_mouse_button_state(_ button: Int32) -> Int32 {
let cgButton = CGMouseButton(rawValue: UInt32(button))!
let state = CGEventSource.buttonState(.combinedSessionState, button: cgButton)
return state ? 1 : 0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.kdroid.composetray.lib.mac

import com.kdroid.composetray.utils.extractToTempIfDifferent
import java.io.File

/**
* JNI bridge to the native macOS tray library (libMacTray.dylib).
* Replaces the previous JNA direct-mapping approach.
* All methods are static JNI calls into MacTrayBridge.m.
*/
internal object MacNativeBridge {

init {
loadNativeLibrary()
}

private fun loadNativeLibrary() {
val arch = System.getProperty("os.arch") ?: "aarch64"
val resourceDir = when {
arch.contains("aarch64") || arch.contains("arm64") -> "darwin-aarch64"
else -> "darwin-x86-64"
}
val resourcePath = "$resourceDir/libMacTray.dylib"

// Try to find the dylib on the classpath (inside a JAR or on disk)
val url = MacNativeBridge::class.java.classLoader?.getResource(resourcePath)
if (url != null) {
val protocol = url.protocol
if (protocol == "jar") {
// Extract from JAR to a temp file
val tempFile = extractToTempIfDifferent(url.toString())
if (tempFile != null) {
System.load(tempFile.absolutePath)
return
}
} else if (protocol == "file") {
// Direct file on disk (development mode)
val file = File(url.toURI())
if (file.exists()) {
System.load(file.absolutePath)
return
}
}
}

// Fallback: let the JVM find it on java.library.path
System.loadLibrary("MacTray")
}

// ── Tray lifecycle ──────────────────────────────────────────────────

@JvmStatic external fun nativeCreateTray(iconPath: String, tooltip: String): Long
@JvmStatic external fun nativeFreeTray(handle: Long)
@JvmStatic external fun nativeSetTrayIcon(handle: Long, iconPath: String)
@JvmStatic external fun nativeSetTrayTooltip(handle: Long, tooltip: String)
@JvmStatic external fun nativeSetTrayCallback(handle: Long, callback: Runnable?)
@JvmStatic external fun nativeSetTrayMenu(trayHandle: Long, menuHandle: Long)
@JvmStatic external fun nativeClearTrayMenu(trayHandle: Long)
@JvmStatic external fun nativeInitTray(handle: Long): Int
@JvmStatic external fun nativeLoopTray(blocking: Int): Int
@JvmStatic external fun nativeUpdateTray(handle: Long)
@JvmStatic external fun nativeDisposeTray(handle: Long)
@JvmStatic external fun nativeExitTray()

// ── Menu items ──────────────────────────────────────────────────────

@JvmStatic external fun nativeCreateMenuItems(count: Int): Long
@JvmStatic external fun nativeSetMenuItem(
menuHandle: Long, index: Int,
text: String, iconPath: String?,
disabled: Int, checked: Int
)
@JvmStatic external fun nativeSetMenuItemCallback(menuHandle: Long, index: Int, callback: Runnable?)
@JvmStatic external fun nativeSetMenuItemSubmenu(menuHandle: Long, index: Int, submenuHandle: Long)
@JvmStatic external fun nativeFreeMenuItems(menuHandle: Long, count: Int)

// ── Theme ───────────────────────────────────────────────────────────

@JvmStatic external fun nativeSetThemeCallback(callback: ThemeChangeCallback?)
@JvmStatic external fun nativeIsMenuDark(): Int

// ── Position ────────────────────────────────────────────────────────

/** Writes [x, y] into outXY. Returns 1 if precise, 0 if fallback. */
@JvmStatic external fun nativeGetStatusItemPosition(outXY: IntArray): Int
@JvmStatic external fun nativeGetStatusItemRegion(): String
@JvmStatic external fun nativeGetStatusItemPositionFor(handle: Long, outXY: IntArray): Int
@JvmStatic external fun nativeGetStatusItemRegionFor(handle: Long): String

// ── Appearance ──────────────────────────────────────────────────────

@JvmStatic external fun nativeSetIconsForAppearance(handle: Long, lightIcon: String, darkIcon: String)

// ── Window management ───────────────────────────────────────────────

@JvmStatic external fun nativeShowInDock(): Int
@JvmStatic external fun nativeHideFromDock(): Int
@JvmStatic external fun nativeSetMoveToActiveSpace()
@JvmStatic external fun nativeSetMoveToActiveSpaceForWindow(viewPtr: Long): Int
@JvmStatic external fun nativeIsFloatingWindowOnActiveSpace(): Int
@JvmStatic external fun nativeBringFloatingWindowToFront(): Int
@JvmStatic external fun nativeIsOnActiveSpaceForView(viewPtr: Long): Int

// ── Mouse ───────────────────────────────────────────────────────────

@JvmStatic external fun nativeGetMouseButtonState(button: Int): Int

// ── JAWT ────────────────────────────────────────────────────────────

/** Returns the NSView pointer for an AWT component, or 0 on failure. */
@JvmStatic external fun nativeGetAWTViewPtr(awtComponent: Any): Long

// ── Callback interface ──────────────────────────────────────────────

interface ThemeChangeCallback {
fun onThemeChanged(isDark: Int)
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
package com.kdroid.composetray.lib.mac

import com.sun.jna.Native
import java.util.function.Consumer
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import com.kdroid.composetray.lib.mac.MacTrayManager.MacTrayLibrary

// Removed kermit Logger import and usage
// private val logger = Logger.withTag("MacOSMenuBarThemeDetector")

object MacOSMenuBarThemeDetector {

private val trayLib: MacTrayLibrary = MacTrayLoader.lib

private val listeners: MutableSet<Consumer<Boolean>> = ConcurrentHashMap.newKeySet()

private val callbackExecutor = Executors.newSingleThreadExecutor { r ->
Thread(r, "MacOS MenuBar Theme Detector Thread").apply { isDaemon = true }
}

private val themeChangedCallback = object : MacTrayManager.ThemeCallback {
override fun invoke(isDark: Int) {
private val themeChangedCallback = object : MacNativeBridge.ThemeChangeCallback {
override fun onThemeChanged(isDark: Int) {
callbackExecutor.execute {
val dark = isDark != 0
notifyListeners(dark)
Expand All @@ -29,11 +22,11 @@ object MacOSMenuBarThemeDetector {
}

init {
trayLib.tray_set_theme_callback(themeChangedCallback)
MacNativeBridge.nativeSetThemeCallback(themeChangedCallback)
}

fun isDark(): Boolean {
return trayLib.tray_is_menu_dark() != 0
return MacNativeBridge.nativeIsMenuDark() != 0
}

fun registerListener(listener: Consumer<Boolean>) {
Expand All @@ -49,4 +42,4 @@ object MacOSMenuBarThemeDetector {
private fun notifyListeners(isDark: Boolean) {
listeners.forEach { it.accept(isDark) }
}
}
}
Loading
Loading