From b1f36f4cd6875bb683c85be545b228ab6006f8cf Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Fri, 28 Aug 2026 02:34:40 +0300 Subject: [PATCH] fix(macos): wait for status item layout before placing TrayApp popup --- .../tray/impl/MacTrayInitializer.kt | 10 ++ src/native/macos/tray.swift | 108 +++++++-------- tray-app/build.gradle.kts | 4 + .../trayapp/AwaitAnchoredWindowPosition.kt | 79 +++++++++++ .../composenativetray/trayapp/TrayApp.kt | 67 ++++++---- .../trayapp/TrayWindowPosition.kt | 31 +++-- .../AwaitAnchoredWindowPositionTest.kt | 64 +++++++++ .../trayapp/MacTrayWindowPositionE2EMain.kt | 126 ++++++++++++++++++ .../trayapp/MacTrayWindowPositionE2ETest.kt | 52 ++++++++ 9 files changed, 447 insertions(+), 94 deletions(-) create mode 100644 tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/AwaitAnchoredWindowPosition.kt create mode 100644 tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/AwaitAnchoredWindowPositionTest.kt create mode 100644 tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/MacTrayWindowPositionE2EMain.kt create mode 100644 tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/MacTrayWindowPositionE2ETest.kt diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt index f45ba55c..cd3f8779 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt @@ -48,6 +48,16 @@ object MacTrayInitializer { return runCatching { MacNativeBridge.nativeGetStatusItemRegionFor(handle) }.getOrNull() } + /** + * Drains one pending AppKit event. TrayApp does this via the Compose main + * dispatcher; tests that query [statusItemPositionFor] off a Compose runtime + * must pump so the status item can lay out. + */ + fun pumpEventLoop() { + if (!MacNativeBridge.isLoaded) return + runCatching { MacNativeBridge.nativeLoopTray(0) } + } + @Synchronized fun initialize( id: String, diff --git a/src/native/macos/tray.swift b/src/native/macos/tray.swift index e64eb9fe..0f87b72f 100644 --- a/src/native/macos/tray.swift +++ b/src/native/macos/tray.swift @@ -424,6 +424,42 @@ public func tray_is_menu_dark() -> Int32 { // MARK: - Status‑item geometry (exported to C) +/// Laid-out status-item rect in screen coordinates, or `nil` if AppKit has not +/// assigned the item a menu-bar slot yet. A brand-new `NSStatusItem` is parked +/// at the menu bar's left edge (relative X ≈ 0); reporting that as precise +/// lands the TrayApp popup at the top-left of the screen. +private struct StatusItemGeometry { + let rect: NSRect + let screen: NSScreen +} + +private func laidOutGeometry(for ctx: TrayContext) -> StatusItemGeometry? { + guard + let button = ctx.statusItem.button, + let window = button.window, + let screen = window.screen + else { return nil } + + var rect = button.convert(button.bounds, to: nil as NSView?) + rect = window.convertToScreen(rect) + guard rect.width >= 1, rect.height >= 1 else { return nil } + + // Menu extras always live on the right of the menu bar. Relative X is + // measured against the screen that owns the item, so a left-arranged + // external display (origin.x < 0) still reports a large relative X. + let relativeX = rect.minX - screen.frame.minX + guard relativeX >= 8 else { return nil } + return StatusItemGeometry(rect: rect, screen: screen) +} + +private func writeFlippedPosition(_ geometry: StatusItemGeometry, + x: UnsafeMutablePointer?, + y: UnsafeMutablePointer?) { + x?.pointee = Int32(lround(geometry.rect.midX)) + let primaryHeight = NSScreen.screens.first?.frame.height ?? geometry.screen.frame.height + y?.pointee = Int32(primaryHeight - geometry.rect.maxY) +} + // Returns 1 if the coordinate is precise, 0 if we had to use a fallback. @_cdecl("tray_get_status_item_position") public func tray_get_status_item_position( @@ -432,31 +468,12 @@ public func tray_get_status_item_position( ) -> Int32 { let ctx = trayInstance.flatMap { contexts[$0] } ?? contexts.values.first - guard - let button = ctx?.statusItem.button, - let window = button.window, - let screen = window.screen - else { + guard let ctx, let geometry = laidOutGeometry(for: ctx) else { x?.pointee = 0 y?.pointee = 0 return 0 // unreliable coordinates } - - // Button frame in screen space (origin at bottom-left) - var rect = button.convert(button.bounds, to: nil as NSView?) - rect = window.convertToScreen(rect) - - // -- X --------------------------------------------------------------- - // Horizontal center of the icon (ideal for centered placement) - x?.pointee = Int32(lround(rect.midX)) - - // -- Y --------------------------------------------------------------- - // Convert macOS bottom-origin to AWT top-origin using primary screen height. - // This produces correct global coordinates for all screens (including external). - let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height - let flippedY = Int32(primaryHeight - rect.maxY) - y?.pointee = flippedY - + writeFlippedPosition(geometry, x: x, y: y) return 1 // precise coordinates } @@ -464,16 +481,11 @@ public func tray_get_status_item_position( @_cdecl("tray_get_status_item_region") public func tray_get_status_item_region() -> UnsafeMutablePointer? { let ctx = trayInstance.flatMap { contexts[$0] } ?? contexts.values.first - guard let button = ctx?.statusItem.button, - let screen = button.window?.screen else { - return strdup("top-right") // default value + guard let ctx, let geometry = laidOutGeometry(for: ctx) else { + return strdup("top-right") // default until the item is laid out } - - let rect = button.window!.convertToScreen( - button.convert(button.bounds, to: nil as NSView?) - ) - let midX = screen.frame.midX - let region = rect.minX < midX ? "top-left" : "top-right" + let midX = geometry.screen.frame.midX + let region = geometry.rect.minX < midX ? "top-left" : "top-right" return strdup(region) // to be freed on JVM/JNI side } @@ -484,29 +496,13 @@ public func tray_get_status_item_position_for( _ x: UnsafeMutablePointer?, _ y: UnsafeMutablePointer? ) -> Int32 { - guard let tray = tray, let ctx = contexts[tray] else { + guard let tray = tray, let ctx = contexts[tray], + let geometry = laidOutGeometry(for: ctx) else { x?.pointee = 0 y?.pointee = 0 return 0 } - guard - let button = ctx.statusItem.button, - let window = button.window, - let screen = window.screen - else { - x?.pointee = 0 - y?.pointee = 0 - return 0 - } - - var rect = button.convert(button.bounds, to: nil as NSView?) - rect = window.convertToScreen(rect) - - x?.pointee = Int32(lround(rect.midX)) - // Convert macOS bottom-origin to AWT top-origin using primary screen height. - let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height - let flippedY = Int32(primaryHeight - rect.maxY) - y?.pointee = flippedY + writeFlippedPosition(geometry, x: x, y: y) return 1 } @@ -514,18 +510,12 @@ public func tray_get_status_item_position_for( public func tray_get_status_item_region_for( _ tray: UnsafeMutableRawPointer? ) -> UnsafeMutablePointer? { - guard let tray = tray, let ctx = contexts[tray] else { - return strdup("top-right") - } - guard let button = ctx.statusItem.button, - let screen = button.window?.screen else { + guard let tray = tray, let ctx = contexts[tray], + let geometry = laidOutGeometry(for: ctx) else { return strdup("top-right") } - let rect = button.window!.convertToScreen( - button.convert(button.bounds, to: nil as NSView?) - ) - let midX = screen.frame.midX - let region = rect.minX < midX ? "top-left" : "top-right" + let midX = geometry.screen.frame.midX + let region = geometry.rect.minX < midX ? "top-left" : "top-right" return strdup(region) } diff --git a/tray-app/build.gradle.kts b/tray-app/build.gradle.kts index 87772310..2de8f1ad 100644 --- a/tray-app/build.gradle.kts +++ b/tray-app/build.gradle.kts @@ -44,6 +44,10 @@ kotlin { api(libs.nucleus.application) implementation(libs.nucleus.decorated.window.tao) } + jvmTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.core) + } } } diff --git a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/AwaitAnchoredWindowPosition.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/AwaitAnchoredWindowPosition.kt new file mode 100644 index 00000000..0be2ff81 --- /dev/null +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/AwaitAnchoredWindowPosition.kt @@ -0,0 +1,79 @@ +package dev.nucleusframework.composenativetray.trayapp + +import androidx.compose.ui.window.WindowPosition +import kotlinx.coroutines.delay +import kotlin.math.abs + +/** + * AppKit parks a brand-new NSStatusItem at the menu-bar's left edge until the + * status bar assigns it a slot. Menu extras always live on the right, so a + * relative X below this threshold means "not laid out yet" — treating it as a + * real anchor puts the popup at the top-left of the screen. + */ +internal const val MAC_STATUS_ITEM_MIN_RELATIVE_X_PX = 24 + +/** + * Waits until [read] yields a usable, stable tray-anchored window position. + * + * The historical poller returned the first non-[WindowPosition.PlatformDefault] + * value. On macOS that is often `Absolute(0, 0)` / the work-area origin: the + * status item exists but has not been assigned a menu-bar slot yet, so any + * `initiallyVisible` TrayApp opens at the top-left on most launches. + */ +internal suspend fun awaitAnchoredWindowPosition( + timeoutMs: Long = 3_000L, + pollDelayMs: Long = 50L, + minStableReads: Int = 2, + isUsable: (WindowPosition) -> Boolean = { isUsableAnchorPosition(it) }, + delayMs: suspend (Long) -> Unit = { delay(it) }, + read: suspend () -> WindowPosition, +): WindowPosition { + require(minStableReads >= 1) + val deadline = System.currentTimeMillis() + timeoutMs + var lastUsable: WindowPosition? = null + var stableCount = 0 + var lastRead: WindowPosition = WindowPosition.PlatformDefault + + while (System.currentTimeMillis() < deadline) { + val pos = read() + lastRead = pos + if (isUsable(pos)) { + if (lastUsable != null && sameAbsolute(lastUsable, pos)) { + stableCount++ + if (stableCount + 1 >= minStableReads) return pos + } else { + lastUsable = pos + stableCount = 0 + if (minStableReads <= 1) return pos + } + } else { + lastUsable = null + stableCount = 0 + } + delayMs(pollDelayMs) + } + return lastUsable ?: lastRead +} + +internal fun isLaidOutMacStatusItem( + x: Int, + @Suppress("UNUSED_PARAMETER") y: Int, + screen: ScreenRect, +): Boolean = (x - screen.x) >= MAC_STATUS_ITEM_MIN_RELATIVE_X_PX + +internal fun isUsableAnchorPosition( + pos: WindowPosition, + workArea: ScreenRect? = null, +): Boolean { + if (pos !is WindowPosition.Absolute) return false + if (pos.x.value == 0f && pos.y.value == 0f) return false + return workArea == null || abs(pos.x.value - workArea.x) >= 1.5f +} + +private fun sameAbsolute( + a: WindowPosition, + b: WindowPosition, +): Boolean { + if (a !is WindowPosition.Absolute || b !is WindowPosition.Absolute) return a == b + return abs(a.x.value - b.x.value) < 1f && abs(a.y.value - b.y.value) < 1f +} diff --git a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayApp.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayApp.kt index 0fe9bc3f..f2ff542f 100644 --- a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayApp.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayApp.kt @@ -623,18 +623,21 @@ private fun TrayAppImplPanel( if (!popupOnScreen) { val preComputed = pendingPosition pendingPosition = null + val workArea = TrayScreenGeometry.workAreaLogical() + val usable = { pos: WindowPosition -> isUsableAnchorPosition(pos, workArea) } val position = - if (preComputed != null && preComputed !is WindowPosition.PlatformDefault) { + if (preComputed != null && usable(preComputed)) { debugln { "[TrayApp] Using preComputed position: $preComputed" } preComputed } else { - // Fallback: poll for position (e.g. visibleOnStart or programmatic show). + // visibleOnStart / programmatic show: the tray icon may not be laid + // out yet. On macOS NSStatusItem reports a left-edge rect until the + // status bar assigns a slot — latching that puts the popup at (0,0). debugln { "[TrayApp] No preComputed position, waiting for tray to stabilize..." } val widthPx = currentWindowSize.width.value.toInt() val heightPx = currentWindowSize.height.value.toInt() - var pos: WindowPosition = WindowPosition.PlatformDefault if (Platform.Current == Platform.Windows) { // Windows moves tray icons around after creation, so wait for the // shell to settle, refresh the cached icon rect, then re-poll. @@ -643,18 +646,20 @@ private fun TrayAppImplPanel( WindowsTrayInitializer.refreshPosition(instanceKey) delay(50) } - // macOS positions precisely up front (status item rect), so it skips - // the stabilization wait and resolves on the first poll below. - val deadline = System.currentTimeMillis() + 3000 - while (pos is WindowPosition.PlatformDefault && System.currentTimeMillis() < deadline) { - pos = - getTrayWindowPositionForInstance( - instanceKey, widthPx, heightPx, horizontalOffset, verticalOffset, - ) - debugln { "[TrayApp] Polled position: $pos" } - if (pos is WindowPosition.PlatformDefault) delay(250) - } - if (pos is WindowPosition.PlatformDefault) { + var pos = + awaitAnchoredWindowPosition(isUsable = usable) { + val next = + getTrayWindowPositionForInstance( + instanceKey, + widthPx, + heightPx, + horizontalOffset, + verticalOffset, + ) + debugln { "[TrayApp] Polled position: $next" } + next + } + if (!usable(pos)) { // Tray never became ready within the deadline — fall back // to the corner heuristic rather than showing at (0,0). pos = getTrayWindowPosition(widthPx, heightPx, horizontalOffset, verticalOffset) @@ -691,7 +696,7 @@ private fun TrayAppImplPanel( val w = currentWindowSize.width.value.toInt() val h = currentWindowSize.height.value.toInt() val pos = getTrayWindowPositionForInstance(instanceKey, w, h, horizontalOffset, verticalOffset) - if (pos is WindowPosition.Absolute) popupScreenPos = pos + if (pos is WindowPosition.Absolute && isUsableAnchorPosition(pos)) popupScreenPos = pos } } @@ -852,15 +857,29 @@ private fun NucleusApplicationScope.TrayAppImplWindow( if (!shouldShowWindow) { val preComputed = pendingPosition pendingPosition = null + val widthPx = currentWindowSize.width.value.toInt() + val heightPx = currentWindowSize.height.value.toInt() + val workArea = TrayScreenGeometry.workAreaLogical() + val usable = { pos: WindowPosition -> isUsableAnchorPosition(pos, workArea) } val position = - preComputed - ?: getTrayWindowPositionForInstance( - instanceKey, - currentWindowSize.width.value.toInt(), - currentWindowSize.height.value.toInt(), - horizontalOffset, - verticalOffset, - ) + if (preComputed != null && usable(preComputed)) { + preComputed + } else { + var pos = + awaitAnchoredWindowPosition(isUsable = usable) { + getTrayWindowPositionForInstance( + instanceKey, + widthPx, + heightPx, + horizontalOffset, + verticalOffset, + ) + } + if (!usable(pos)) { + pos = getTrayWindowPosition(widthPx, heightPx, horizontalOffset, verticalOffset) + } + pos + } windowState.position = position delay(30) shouldShowWindow = true diff --git a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt index 111f407b..1a5984f6 100644 --- a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt @@ -80,16 +80,19 @@ fun getTrayWindowPosition( if (os == Platform.MacOS) { val outXY = IntArray(2) if (MacTrayInitializer.statusItemPosition(outXY)) { - val corner = getTrayPosition() - return calculateWindowPositionFromClick( - outXY[0], - outXY[1], - corner, - windowWidth, - windowHeight, - horizontalOffset, - verticalOffset, - ) + val screen = TrayScreenGeometry.workAreaLogical() + if (isLaidOutMacStatusItem(outXY[0], outXY[1], screen)) { + val corner = getTrayPosition() + return calculateWindowPositionFromClick( + outXY[0], + outXY[1], + corner, + windowWidth, + windowHeight, + horizontalOffset, + verticalOffset, + ) + } } } @@ -146,12 +149,18 @@ fun getTrayWindowPositionForInstance( if (MacTrayInitializer.statusItemPositionFor(instanceId, outXY)) { val x = outXY[0] val y = outXY[1] + val bounds = getScreenBoundsAt(x, y) + if (!isLaidOutMacStatusItem(x, y, bounds)) { + debugln { + "[TrayPosition] mac instance $instanceId unlaid-out ($x,$y), PlatformDefault" + } + return WindowPosition.PlatformDefault + } val regionStr = MacTrayInitializer.statusItemRegionFor(instanceId) val trayPos = if (regionStr != null) { getMacTrayPosition(regionStr) } else { - val bounds = getScreenBoundsAt(x, y) convertPositionToCorner(x - bounds.x, y - bounds.y, bounds.width, bounds.height) } return calculateWindowPositionFromClick( diff --git a/tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/AwaitAnchoredWindowPositionTest.kt b/tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/AwaitAnchoredWindowPositionTest.kt new file mode 100644 index 00000000..98e07fa8 --- /dev/null +++ b/tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/AwaitAnchoredWindowPositionTest.kt @@ -0,0 +1,64 @@ +package dev.nucleusframework.composenativetray.trayapp + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class AwaitAnchoredWindowPositionTest { + @Test + fun `skips origin placeholder and waits for a stable tray-anchored position`() = + runBlocking { + val frames = + ArrayDeque( + listOf( + WindowPosition.PlatformDefault, + WindowPosition.Absolute(0.dp, 0.dp), + WindowPosition.Absolute(0.dp, 25.dp), + WindowPosition.Absolute(1_280.dp, 25.dp), + WindowPosition.Absolute(1_280.dp, 25.dp), + ), + ) + val workArea = ScreenRect(x = 0, y = 25, width = 1_440, height = 875) + val pos = + awaitAnchoredWindowPosition( + timeoutMs = 2_000, + pollDelayMs = 1, + isUsable = { isUsableAnchorPosition(it, workArea) }, + ) { + if (frames.isEmpty()) { + WindowPosition.Absolute(1_280.dp, 25.dp) + } else { + frames.removeFirst() + } + } + + val absolute = assertIs(pos) + assertEquals(1_280f, absolute.x.value) + assertEquals(25f, absolute.y.value) + } + + @Test + fun `does not latch the first unlaid-out mac status item coordinate`() { + val screen = ScreenRect(x = 0, y = 0, width = 1_440, height = 900) + assertFalse(isLaidOutMacStatusItem(x = 0, y = 0, screen = screen)) + assertFalse(isLaidOutMacStatusItem(x = 11, y = 0, screen = screen)) + assertTrue(isLaidOutMacStatusItem(x = 1_380, y = 0, screen = screen)) + val leftDisplay = ScreenRect(x = -1_920, y = 0, width = 1_920, height = 1_080) + assertTrue(isLaidOutMacStatusItem(x = -200, y = 0, screen = leftDisplay)) + assertFalse(isLaidOutMacStatusItem(x = -1_920, y = 0, screen = leftDisplay)) + } + + @Test + fun `work-area origin window is not a usable mac tray anchor`() { + val workArea = ScreenRect(x = 0, y = 25, width = 1_440, height = 875) + assertFalse(isUsableAnchorPosition(WindowPosition.PlatformDefault, workArea)) + assertFalse(isUsableAnchorPosition(WindowPosition.Absolute(0.dp, 0.dp), workArea)) + assertFalse(isUsableAnchorPosition(WindowPosition.Absolute(0.dp, 25.dp), workArea)) + assertTrue(isUsableAnchorPosition(WindowPosition.Absolute(1_100.dp, 25.dp), workArea)) + } +} diff --git a/tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/MacTrayWindowPositionE2EMain.kt b/tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/MacTrayWindowPositionE2EMain.kt new file mode 100644 index 00000000..4adc44cf --- /dev/null +++ b/tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/MacTrayWindowPositionE2EMain.kt @@ -0,0 +1,126 @@ +package dev.nucleusframework.composenativetray.trayapp + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import dev.nucleusframework.composenativetray.tray.impl.MacTrayInitializer +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.Base64 +import kotlin.system.exitProcess + +/** + * End-to-end for the macOS `initiallyVisible` race: create a real NSStatusItem + * and resolve its position the same way [TrayApp] does — [awaitAnchoredWindowPosition] + * over [MacTrayInitializer.statusItemPositionFor] + [isLaidOutMacStatusItem]. + * + * Does not touch Tao/Nucleus (those hang without `nucleusApplication`). Prints + * `RESULT=OK` when the anchor is the laid-out tray, or `RESULT=TOP_LEFT` when + * it latched the unlaid-out origin (the AeroDL bug). + */ +object MacTrayWindowPositionE2EMain { + private const val TRIALS = 6 + private val fallbackScreen = ScreenRect(x = 0, y = 0, width = 1_920, height = 1_080) + + @JvmStatic + fun main(args: Array) { + if (!System.getProperty("os.name").orEmpty().lowercase().contains("mac")) { + println("RESULT=SKIP") + return + } + val icon = writeTempPng() + var topLeft = 0 + try { + repeat(TRIALS) { trial -> + val id = "e2e-tray-$trial" + MacTrayInitializer.initialize(id, icon.absolutePath, "e2e") + try { + val immediate = IntArray(2) + val immediatePrecise = MacTrayInitializer.statusItemPositionFor(id, immediate) + println( + "TRIAL=$trial IMMEDIATE_PRECISE=$immediatePrecise " + + "IMMEDIATE_XY=${immediate[0]},${immediate[1]}", + ) + + val pos = + runBlocking { + awaitAnchoredWindowPosition( + timeoutMs = 3_000, + pollDelayMs = 50, + delayMs = { ms -> pumpFor(ms) }, + isUsable = { candidate -> + candidate is WindowPosition.Absolute && + isLaidOutMacStatusItem( + candidate.x.value.toInt(), + candidate.y.value.toInt(), + fallbackScreen, + ) + }, + ) { + MacTrayInitializer.pumpEventLoop() + readTrayAnchor(id) + } + } + + val settled = IntArray(2) + val settledPrecise = MacTrayInitializer.statusItemPositionFor(id, settled) + println( + "TRIAL=$trial SETTLED_PRECISE=$settledPrecise " + + "SETTLED_XY=${settled[0]},${settled[1]} ANCHOR=$pos", + ) + + val absolute = pos as? WindowPosition.Absolute + val latchedTopLeft = + absolute == null || + !isLaidOutMacStatusItem( + absolute.x.value.toInt(), + absolute.y.value.toInt(), + fallbackScreen, + ) + if (latchedTopLeft) { + topLeft++ + println("TRIAL=$trial RESULT=TOP_LEFT") + } else { + println("TRIAL=$trial RESULT=OK") + } + } finally { + MacTrayInitializer.dispose(id) + } + } + } finally { + icon.delete() + } + if (topLeft > 0) { + println("RESULT=TOP_LEFT failures=$topLeft/$TRIALS") + exitProcess(2) + } + println("RESULT=OK") + } + + private fun readTrayAnchor(id: String): WindowPosition { + val xy = IntArray(2) + if (!MacTrayInitializer.statusItemPositionFor(id, xy)) { + return WindowPosition.PlatformDefault + } + if (!isLaidOutMacStatusItem(xy[0], xy[1], fallbackScreen)) { + return WindowPosition.PlatformDefault + } + return WindowPosition.Absolute(xy[0].dp, xy[1].dp) + } + + private fun pumpFor(ms: Long) { + val deadline = System.currentTimeMillis() + ms + while (System.currentTimeMillis() < deadline) { + MacTrayInitializer.pumpEventLoop() + Thread.sleep(5) + } + } + + private fun writeTempPng(): File { + // 1×1 PNG — NSStatusItem only needs a file on disk; layout, not pixels, is under test. + val bytes = + Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==", + ) + return File.createTempFile("cnt-e2e-tray", ".png").apply { writeBytes(bytes) } + } +} diff --git a/tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/MacTrayWindowPositionE2ETest.kt b/tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/MacTrayWindowPositionE2ETest.kt new file mode 100644 index 00000000..c8c6e11a --- /dev/null +++ b/tray-app/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/trayapp/MacTrayWindowPositionE2ETest.kt @@ -0,0 +1,52 @@ +package dev.nucleusframework.composenativetray.trayapp + +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * End-to-end coverage for the AeroDL "window opens at the top-left instead of + * the tray" race. Spawns a dedicated JVM with `-XstartOnFirstThread` so AppKit + * can create a real NSStatusItem, then asserts [awaitAnchoredWindowPosition] + * never latches the unlaid-out origin. + */ +class MacTrayWindowPositionE2ETest { + @Test + fun `initially visible tray popup is not placed at the work-area origin`() { + if (!isMac) return + + val java = + ProcessHandle.current().info().command().orElseThrow { + IllegalStateException("Cannot resolve the current java executable") + } + val classpath = System.getProperty("java.class.path") + val process = + ProcessBuilder( + java, + "-XstartOnFirstThread", + "-Djava.awt.headless=false", + "-cp", + classpath, + MacTrayWindowPositionE2EMain::class.java.name, + ).redirectErrorStream(true).start() + + val finished = process.waitFor(45, TimeUnit.SECONDS) + val output = process.inputStream.bufferedReader().readText() + if (!finished) { + process.destroyForcibly() + fail("e2e JVM timed out.\n$output") + } + assertTrue( + output.contains("RESULT=OK"), + "tray popup latched the top-left instead of the status item:\n$output", + ) + assertTrue( + !output.contains("RESULT=TOP_LEFT"), + "at least one trial opened at the work-area origin:\n$output", + ) + } + + private val isMac: Boolean + get() = System.getProperty("os.name").orEmpty().lowercase().contains("mac") +}