diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml index e625138..8df4c55 100644 --- a/.github/workflows/build-natives.yaml +++ b/.github/workflows/build-natives.yaml @@ -25,9 +25,9 @@ jobs: - name: Verify macOS natives run: | test -f build/nativeLibs/darwin-aarch64/libMacTray.dylib - test -f build/nativeLibs/darwin-x86-64/libMacTray.dylib + test -f build/nativeLibs/darwin-x64/libMacTray.dylib ls -la build/nativeLibs/darwin-aarch64/ - ls -la build/nativeLibs/darwin-x86-64/ + ls -la build/nativeLibs/darwin-x64/ - name: Upload macOS ARM64 library uses: actions/upload-artifact@v4 @@ -39,8 +39,8 @@ jobs: - name: Upload macOS x86_64 library uses: actions/upload-artifact@v4 with: - name: native-darwin-x86-64 - path: build/nativeLibs/darwin-x86-64/libMacTray.dylib + name: native-darwin-x64 + path: build/nativeLibs/darwin-x64/libMacTray.dylib retention-days: 1 build-native-linux: @@ -49,7 +49,7 @@ jobs: matrix: include: - os: ubuntu-latest - arch: x86-64 + arch: x64 - os: ubuntu-24.04-arm arch: aarch64 steps: @@ -106,10 +106,10 @@ jobs: - name: Verify Windows natives shell: bash run: | - test -f build/nativeLibs/win32-x86-64/WinTray.dll - test -f build/nativeLibs/win32-arm64/WinTray.dll - ls -la build/nativeLibs/win32-x86-64/ - ls -la build/nativeLibs/win32-arm64/ + test -f build/nativeLibs/win32-x64/WinTray.dll + test -f build/nativeLibs/win32-aarch64/WinTray.dll + ls -la build/nativeLibs/win32-x64/ + ls -la build/nativeLibs/win32-aarch64/ # Regression guard for issue #401: WinTray.dll must stay AVX-free so it does # not crash with EXCEPTION_ILLEGAL_INSTRUCTION on pre-2011 CPUs (no AVX). @@ -126,7 +126,7 @@ jobs: Where-Object { $_.FullName -match '\\Hostx64\\x64\\' } | Select-Object -First 1 if (-not $dumpbin) { throw "dumpbin.exe not found" } Write-Host "Using $($dumpbin.FullName)" - $disasm = & $dumpbin.FullName /DISASM:NOBYTES "build/nativeLibs/win32-x86-64/WinTray.dll" + $disasm = & $dumpbin.FullName /DISASM:NOBYTES "build/nativeLibs/win32-x64/WinTray.dll" $avx = $disasm | Select-String -Pattern '^\s+[0-9A-Fa-f]+:\s+v[a-z]' | Where-Object { $_.Line -notmatch '\bv(err|erw)\b' } if ($avx) { @@ -139,13 +139,13 @@ jobs: - name: Upload Windows x64 library uses: actions/upload-artifact@v4 with: - name: native-win32-x86-64 - path: build/nativeLibs/win32-x86-64/WinTray.dll + name: native-win32-x64 + path: build/nativeLibs/win32-x64/WinTray.dll retention-days: 1 - name: Upload Windows ARM64 library uses: actions/upload-artifact@v4 with: - name: native-win32-arm64 - path: build/nativeLibs/win32-arm64/WinTray.dll + name: native-win32-aarch64 + path: build/nativeLibs/win32-aarch64/WinTray.dll retention-days: 1 diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index fb9f8ba..3e9dd46 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -30,14 +30,14 @@ jobs: - name: Download macOS x86_64 library uses: actions/download-artifact@v4 with: - name: native-darwin-x86-64 - path: src/jvmMain/resources/composetray/native/darwin-x86-64/ + name: native-darwin-x64 + path: src/jvmMain/resources/composetray/native/darwin-x64/ - name: Download Linux x86_64 library uses: actions/download-artifact@v4 with: - name: native-linux-x86-64 - path: src/jvmMain/resources/composetray/native/linux-x86-64/ + name: native-linux-x64 + path: src/jvmMain/resources/composetray/native/linux-x64/ - name: Download Linux aarch64 library uses: actions/download-artifact@v4 @@ -48,24 +48,24 @@ jobs: - name: Download Windows x64 library uses: actions/download-artifact@v4 with: - name: native-win32-x86-64 - path: src/jvmMain/resources/composetray/native/win32-x86-64/ + name: native-win32-x64 + path: src/jvmMain/resources/composetray/native/win32-x64/ - name: Download Windows ARM64 library uses: actions/download-artifact@v4 with: - name: native-win32-arm64 - path: src/jvmMain/resources/composetray/native/win32-arm64/ + name: native-win32-aarch64 + path: src/jvmMain/resources/composetray/native/win32-aarch64/ - name: Verify native libraries run: | echo "=== Verifying native libraries ===" test -f src/jvmMain/resources/composetray/native/darwin-aarch64/libMacTray.dylib - test -f src/jvmMain/resources/composetray/native/darwin-x86-64/libMacTray.dylib - test -f src/jvmMain/resources/composetray/native/linux-x86-64/libLinuxTray.so + test -f src/jvmMain/resources/composetray/native/darwin-x64/libMacTray.dylib + test -f src/jvmMain/resources/composetray/native/linux-x64/libLinuxTray.so test -f src/jvmMain/resources/composetray/native/linux-aarch64/libLinuxTray.so - test -f src/jvmMain/resources/composetray/native/win32-x86-64/WinTray.dll - test -f src/jvmMain/resources/composetray/native/win32-arm64/WinTray.dll + test -f src/jvmMain/resources/composetray/native/win32-x64/WinTray.dll + test -f src/jvmMain/resources/composetray/native/win32-aarch64/WinTray.dll echo "All native libraries present." - name: Set up JDK diff --git a/.github/workflows/publish-on-maven.yml b/.github/workflows/publish-on-maven.yml index 55cd0bb..e9498e7 100644 --- a/.github/workflows/publish-on-maven.yml +++ b/.github/workflows/publish-on-maven.yml @@ -26,14 +26,14 @@ jobs: - name: Download macOS x86_64 library uses: actions/download-artifact@v4 with: - name: native-darwin-x86-64 - path: src/jvmMain/resources/composetray/native/darwin-x86-64/ + name: native-darwin-x64 + path: src/jvmMain/resources/composetray/native/darwin-x64/ - name: Download Linux x86_64 library uses: actions/download-artifact@v4 with: - name: native-linux-x86-64 - path: src/jvmMain/resources/composetray/native/linux-x86-64/ + name: native-linux-x64 + path: src/jvmMain/resources/composetray/native/linux-x64/ - name: Download Linux aarch64 library uses: actions/download-artifact@v4 @@ -44,24 +44,24 @@ jobs: - name: Download Windows x64 library uses: actions/download-artifact@v4 with: - name: native-win32-x86-64 - path: src/jvmMain/resources/composetray/native/win32-x86-64/ + name: native-win32-x64 + path: src/jvmMain/resources/composetray/native/win32-x64/ - name: Download Windows ARM64 library uses: actions/download-artifact@v4 with: - name: native-win32-arm64 - path: src/jvmMain/resources/composetray/native/win32-arm64/ + name: native-win32-aarch64 + path: src/jvmMain/resources/composetray/native/win32-aarch64/ - name: Verify native libraries run: | echo "=== Native libraries in resources ===" ls -la src/jvmMain/resources/composetray/native/darwin-aarch64/ - ls -la src/jvmMain/resources/composetray/native/darwin-x86-64/ - ls -la src/jvmMain/resources/composetray/native/linux-x86-64/ + ls -la src/jvmMain/resources/composetray/native/darwin-x64/ + ls -la src/jvmMain/resources/composetray/native/linux-x64/ ls -la src/jvmMain/resources/composetray/native/linux-aarch64/ - ls -la src/jvmMain/resources/composetray/native/win32-x86-64/ - ls -la src/jvmMain/resources/composetray/native/win32-arm64/ + ls -la src/jvmMain/resources/composetray/native/win32-x64/ + ls -la src/jvmMain/resources/composetray/native/win32-aarch64/ - name: Set up JDK uses: actions/setup-java@v4 diff --git a/build.gradle.kts b/build.gradle.kts index bd58ef8..183e55b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -85,7 +85,7 @@ val buildNativeWindows by tasks.registering(Exec::class) { group = "build" val hasPrebuilt = nativeResourceDir - .dir("win32-x86-64") + .dir("win32-x64") .file("WinTray.dll") .asFile .exists() @@ -103,7 +103,7 @@ val buildNativeLinux by tasks.registering(Exec::class) { group = "build" val hasPrebuilt = nativeResourceDir - .dir("linux-x86-64") + .dir("linux-x64") .file("libLinuxTray.so") .asFile .exists() diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxNativeBridge.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxNativeBridge.kt index b49ac1b..544ae3a 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxNativeBridge.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxNativeBridge.kt @@ -1,6 +1,6 @@ package dev.nucleusframework.composenativetray.lib.linux -import dev.nucleusframework.composenativetray.utils.NativeLibraryLoader +import dev.nucleusframework.core.runtime.NativeLibraryLoader /** * JNI bridge to the native Linux tray library (libLinuxTray.so). @@ -9,7 +9,11 @@ import dev.nucleusframework.composenativetray.utils.NativeLibraryLoader */ internal object LinuxNativeBridge { init { - NativeLibraryLoader.load("LinuxTray", LinuxNativeBridge::class.java) + NativeLibraryLoader.load( + "LinuxTray", + LinuxNativeBridge::class.java, + resourcePrefix = "/composetray/native", + ) } // -- Lifecycle --------------------------------------------------------------- diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacNativeBridge.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacNativeBridge.kt index ac7da0a..b5941e2 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacNativeBridge.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacNativeBridge.kt @@ -1,6 +1,6 @@ package dev.nucleusframework.composenativetray.lib.mac -import dev.nucleusframework.composenativetray.utils.NativeLibraryLoader +import dev.nucleusframework.core.runtime.NativeLibraryLoader /** * JNI bridge to the native macOS tray library (libMacTray.dylib). @@ -8,7 +8,12 @@ import dev.nucleusframework.composenativetray.utils.NativeLibraryLoader */ internal object MacNativeBridge { private const val LIBRARY_NAME = "MacTray" - private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, MacNativeBridge::class.java) + private val loaded = + NativeLibraryLoader.load( + LIBRARY_NAME, + MacNativeBridge::class.java, + resourcePrefix = "/composetray/native", + ) val isLoaded: Boolean get() = loaded // ── Tray lifecycle ────────────────────────────────────────────────── diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsNativeBridge.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsNativeBridge.kt index b35e9e2..1c8d8fc 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsNativeBridge.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsNativeBridge.kt @@ -1,6 +1,6 @@ package dev.nucleusframework.composenativetray.lib.windows -import dev.nucleusframework.composenativetray.utils.NativeLibraryLoader +import dev.nucleusframework.core.runtime.NativeLibraryLoader /** * JNI bridge to the native Windows tray library (WinTray.dll). @@ -9,7 +9,11 @@ import dev.nucleusframework.composenativetray.utils.NativeLibraryLoader */ internal object WindowsNativeBridge { init { - NativeLibraryLoader.load("WinTray", WindowsNativeBridge::class.java) + NativeLibraryLoader.load( + "WinTray", + WindowsNativeBridge::class.java, + resourcePrefix = "/composetray/native", + ) } // -- Tray lifecycle -- diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/NativeLibraryLoader.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/NativeLibraryLoader.kt deleted file mode 100644 index 8903d0e..0000000 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/NativeLibraryLoader.kt +++ /dev/null @@ -1,132 +0,0 @@ -package dev.nucleusframework.composenativetray.utils - -import java.io.File -import java.nio.file.Files -import java.nio.file.StandardCopyOption - -/** - * Loads native libraries following the Nucleus pattern: - * 1. Try [System.loadLibrary] (works for packaged apps / GraalVM native-image - * where the lib sits on `java.library.path`). - * 2. Fallback: extract from the classpath (`composetray/native//`) - * into a persistent cache (`~/.cache/composetray/native//`) - * and load from there. - */ -internal object NativeLibraryLoader { - private const val RESOURCE_PREFIX = "composetray/native" - private val loadedLibraries = mutableSetOf() - - /** - * Loads a JNI library via [System.load] / [System.loadLibrary]. - * Returns `true` if the library was loaded successfully. - */ - @Synchronized - fun load( - libraryName: String, - callerClass: Class<*>, - ): Boolean { - if (libraryName in loadedLibraries) return true - - // 1. Try system library path (packaged app / GraalVM native-image) - try { - System.loadLibrary(libraryName) - loadedLibraries += libraryName - return true - } catch (_: UnsatisfiedLinkError) { - // Not on java.library.path, try classpath extraction - } - - // 2. Extract from classpath to persistent cache - val file = extractToCache(libraryName, callerClass) ?: return false - System.load(file.absolutePath) - loadedLibraries += libraryName - return true - } - - // ── Shared extraction logic ───────────────────────────────────────── - - private fun extractToCache( - libraryName: String, - callerClass: Class<*>, - ): File? { - val platform = detectPlatform() - val fileName = mapLibraryName(libraryName) - val resourcePath = "$RESOURCE_PREFIX/$platform/$fileName" - - val resourceUrl = callerClass.classLoader?.getResource(resourcePath) ?: return null - - val cacheDir = resolveCacheDir(platform) - cacheDir.mkdirs() - val cachedFile = File(cacheDir, fileName) - - // Validate the cache by content hash, not by size. Two builds of the same - // source can produce libraries of identical byte size but different code - // (e.g. the AVX vs SSE2 build of WinTray.dll — both 23552 bytes — see #401). - // A size-only check would keep serving a stale cached library after the user - // upgrades, so the original crash survives the fix. Compare hashes instead. - val resourceHash = resourceUrl.openStream().use { it.sha256() } - if (isCacheUpToDate(cachedFile, resourceHash)) { - return cachedFile - } - - // Atomic extract: write to temp then move - val tmpFile = File(cacheDir, "$fileName.tmp") - try { - resourceUrl.openStream().use { input -> - Files.copy(input, tmpFile.toPath(), StandardCopyOption.REPLACE_EXISTING) - } - Files.move(tmpFile.toPath(), cachedFile.toPath(), StandardCopyOption.REPLACE_EXISTING) - cachedFile.setExecutable(true) - } finally { - tmpFile.delete() - } - - return cachedFile - } - - /** - * A cached library is valid only if it exists and its content hash matches the - * resource currently on the classpath. Content-based (not size-based) so that an - * upgraded library replaces a same-sized stale one (see #401). - */ - internal fun isCacheUpToDate( - cachedFile: File, - expectedSha256: String, - ): Boolean = cachedFile.exists() && cachedFile.sha256() == expectedSha256 - - private fun resolveCacheDir(platform: String): File { - val os = System.getProperty("os.name")?.lowercase() ?: "" - val base = - when { - os.contains("win") -> File(System.getenv("LOCALAPPDATA") ?: System.getProperty("user.home")) - else -> File(System.getProperty("user.home"), ".cache") - } - return File(base, "composetray/native/$platform") - } - - private fun detectPlatform(): String { - val os = System.getProperty("os.name")?.lowercase() ?: "" - val arch = System.getProperty("os.arch") ?: "" - return when { - os.contains( - "win", - ) -> if (arch.contains("aarch64") || arch.contains("arm")) "win32-arm64" else "win32-x86-64" - os.contains( - "linux", - ) -> if (arch.contains("aarch64") || arch.contains("arm")) "linux-aarch64" else "linux-x86-64" - os.contains( - "mac", - ) -> if (arch.contains("aarch64") || arch.contains("arm")) "darwin-aarch64" else "darwin-x86-64" - else -> "unknown" - } - } - - private fun mapLibraryName(name: String): String { - val os = System.getProperty("os.name")?.lowercase() ?: "" - return when { - os.contains("win") -> "$name.dll" - os.contains("mac") -> "lib$name.dylib" - else -> "lib$name.so" - } - } -} diff --git a/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/Issue534OldVersionMain.kt b/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/Issue534OldVersionMain.kt new file mode 100644 index 0000000..118d7f7 --- /dev/null +++ b/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/Issue534OldVersionMain.kt @@ -0,0 +1,102 @@ +package dev.nucleusframework.composenativetray.utils + +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import kotlin.system.exitProcess + +/** + * 2.0.3 [NativeLibraryLoader] verbatim: fixed shared cache path + hash check + + * `Files.move(REPLACE_EXISTING)`. Used by [Issue534ReproTest] as the "other + * version" process. + */ +private object Legacy203NativeLibraryLoader { + private const val RESOURCE_PREFIX = "composetray/native" + private val loadedLibraries = mutableSetOf() + + @Synchronized + fun load( + libraryName: String, + callerClass: Class<*>, + ): Boolean { + if (libraryName in loadedLibraries) return true + try { + System.loadLibrary(libraryName) + loadedLibraries += libraryName + return true + } catch (_: UnsatisfiedLinkError) { + // Not on java.library.path + } + val file = extractToCache(libraryName, callerClass) ?: return false + System.load(file.absolutePath) + loadedLibraries += libraryName + return true + } + + private fun extractToCache( + libraryName: String, + callerClass: Class<*>, + ): File? { + val os = System.getProperty("os.name")?.lowercase() ?: "" + val arch = System.getProperty("os.arch") ?: "" + val platform = + if (arch.contains("aarch64") || arch.contains("arm")) "win32-arm64" else "win32-x86-64" + val fileName = if (os.contains("win")) "$libraryName.dll" else return null + val resourcePath = "$RESOURCE_PREFIX/$platform/$fileName" + val resourceUrl = callerClass.classLoader?.getResource(resourcePath) ?: return null + + val base = File(System.getenv("LOCALAPPDATA") ?: System.getProperty("user.home")) + val cacheDir = File(base, "composetray/native/$platform") + cacheDir.mkdirs() + val cachedFile = File(cacheDir, fileName) + + val resourceHash = resourceUrl.openStream().use { it.sha256() } + if (cachedFile.exists() && cachedFile.sha256() == resourceHash) { + return cachedFile + } + + val tmpFile = File(cacheDir, "$fileName.tmp") + try { + resourceUrl.openStream().use { input -> + Files.copy(input, tmpFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + Files.move(tmpFile.toPath(), cachedFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + cachedFile.setExecutable(true) + } finally { + tmpFile.delete() + } + return cachedFile + } +} + +/** + * Two-JVM helper: load WinTray the way ComposeNativeTray 2.0.3 did. + * + * `--hold` keeps the process alive (DLL mapped) until `--stop-file=` appears. + */ +object Issue534OldVersionMain { + @JvmStatic + fun main(args: Array) { + try { + val ok = Legacy203NativeLibraryLoader.load("WinTray", Issue534OldVersionMain::class.java) + println("LOAD_OK=$ok") + System.out.flush() + if ("--hold" in args) { + val stopPath = + args.firstOrNull { it.startsWith("--stop-file=") } + ?.removePrefix("--stop-file=") + ?: error("--hold requires --stop-file=") + val stopFile = File(stopPath) + println("HOLDING") + System.out.flush() + while (!stopFile.exists()) { + Thread.sleep(50) + } + } + } catch (t: Throwable) { + System.err.println("LOAD_FAIL=${t.javaClass.name}: ${t.message}") + t.printStackTrace() + exitProcess(1) + } + } +} diff --git a/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/Issue534ReproMain.kt b/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/Issue534ReproMain.kt new file mode 100644 index 0000000..0764977 --- /dev/null +++ b/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/Issue534ReproMain.kt @@ -0,0 +1,43 @@ +package dev.nucleusframework.composenativetray.utils + +import dev.nucleusframework.core.runtime.NativeLibraryLoader +import java.io.File +import kotlin.system.exitProcess + +/** + * Two-JVM helper for [Issue534ReproTest]. + * + * `--hold` loads WinTray and stays alive so the OS keeps that extracted DLL locked. + * `--extract` is a second process that loads a different WinTray binary. + */ +object Issue534ReproMain { + @JvmStatic + fun main(args: Array) { + try { + val ok = + NativeLibraryLoader.load( + "WinTray", + Issue534ReproMain::class.java, + resourcePrefix = "/composetray/native", + ) + println("LOAD_OK=$ok") + System.out.flush() + if ("--hold" in args) { + val stopPath = + args.firstOrNull { it.startsWith("--stop-file=") } + ?.removePrefix("--stop-file=") + ?: error("--hold requires --stop-file=") + val stopFile = File(stopPath) + println("HOLDING") + System.out.flush() + while (!stopFile.exists()) { + Thread.sleep(50) + } + } + } catch (t: Throwable) { + System.err.println("LOAD_FAIL=${t.javaClass.name}: ${t.message}") + t.printStackTrace() + exitProcess(1) + } + } +} diff --git a/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/Issue534ReproTest.kt b/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/Issue534ReproTest.kt new file mode 100644 index 0000000..19b9154 --- /dev/null +++ b/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/Issue534ReproTest.kt @@ -0,0 +1,252 @@ +package dev.nucleusframework.composenativetray.utils + +import java.io.File +import java.nio.file.AccessDeniedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * End-to-end coverage for Nucleus #534 / ComposeNativeTray #426. + * + * Windows locks a DLL after `System.load`. The old ComposeNativeTray loader + * extracted every process to the same path and `Files.move(REPLACE_EXISTING)` + * onto that lock. Nucleus's content-addressed loader must keep two different + * WinTray binaries on two distinct cache files so the second JVM never tries + * to replace the first. + */ +class Issue534ReproTest { + @Test + fun `windows denies Files move onto a System load ed WinTray dll`() { + if (!isWindows) return + val published = publishedWinTray() + + val dir = Files.createTempDirectory("cnt-534-lock-").toFile() + try { + val locked = File(dir, "WinTray.dll") + published.copyTo(locked, overwrite = true) + System.load(locked.absolutePath) + + val incoming = File(dir, "WinTray.dll.tmp") + incoming.writeBytes(ByteArray(64) { 0x5A }) + + val thrown = + runCatching { + Files.move(incoming.toPath(), locked.toPath(), StandardCopyOption.REPLACE_EXISTING) + }.exceptionOrNull() + + assertIs( + thrown, + "Windows must deny replacing a loaded DLL (the old loader did this)", + ) + } finally { + dir.deleteRecursively() + } + } + + @Test + fun `second jvm with a different WinTray binary must not throw AccessDenied`() { + if (!isWindows) return + + val java = + ProcessHandle.current().info().command().orElseThrow { + IllegalStateException("Cannot resolve the current java executable") + } + val classpath = System.getProperty("java.class.path") + val main = Issue534ReproMain::class.java.name + val published = publishedWinTray() + + val fakeRoot = Files.createTempDirectory("cnt-534-fake-").toFile() + val stopFile = File(fakeRoot, "stop-holder") + try { + val fakeDll = File(fakeRoot, "composetray/native/win32-x64/WinTray.dll") + fakeDll.parentFile.mkdirs() + // Overlay keeps the PE loadable but changes the Nucleus fingerprint + // so the second JVM extracts to a different cache directory. + fakeDll.writeBytes(published.readBytes() + byteArrayOf(0x00, 0x53, 0x34)) + + val holder = + ProcessBuilder( + java, + "-cp", + classpath, + main, + "--hold", + "--stop-file=${stopFile.absolutePath}", + ).redirectErrorStream(true).start() + try { + check(waitForLine(holder, "HOLDING")) { "holder did not reach HOLDING" } + + val second = + ProcessBuilder( + java, + "-cp", + fakeRoot.absolutePath + File.pathSeparator + classpath, + main, + "--extract", + ).redirectErrorStream(true).start() + + val finished = second.waitFor(30, TimeUnit.SECONDS) + val output = second.inputStream.bufferedReader().readText() + if (!finished) { + second.destroyForcibly() + fail("second JVM timed out.\n$output") + } + + assertFalse( + output.contains("AccessDeniedException"), + "second JVM hit AccessDeniedException while loading a different WinTray.dll:\n$output", + ) + assertEquals(0, second.exitValue(), "second JVM exited ${second.exitValue()}:\n$output") + assertTrue(output.contains("LOAD_OK=true"), "second JVM did not load WinTray:\n$output") + } finally { + stopFile.writeText("stop") + if (!holder.waitFor(5, TimeUnit.SECONDS)) { + ProcessBuilder("taskkill", "/F", "/T", "/PID", holder.pid().toString()) + .inheritIO() + .start() + .waitFor(5, TimeUnit.SECONDS) + holder.waitFor(5, TimeUnit.SECONDS) + } + } + } finally { + fakeRoot.deleteRecursively() + } + } + + @Test + fun `two same-version processes can both load WinTray`() { + if (!isWindows) return + + val java = currentJava() + val classpath = System.getProperty("java.class.path") + val main = Issue534ReproMain::class.java.name + val dir = Files.createTempDirectory("cnt-534-same-").toFile() + val stopA = File(dir, "stop-a") + val stopB = File(dir, "stop-b") + try { + val first = + ProcessBuilder(java, "-cp", classpath, main, "--hold", "--stop-file=${stopA.absolutePath}") + .redirectErrorStream(true) + .start() + val second = + ProcessBuilder(java, "-cp", classpath, main, "--hold", "--stop-file=${stopB.absolutePath}") + .redirectErrorStream(true) + .start() + try { + check(waitForLine(first, "HOLDING")) { "first process did not reach HOLDING" } + check(waitForLine(second, "HOLDING")) { "second process did not reach HOLDING" } + assertTrue(first.isAlive, "first process died after load") + assertTrue(second.isAlive, "second process died after load") + } finally { + stopA.writeText("stop") + stopB.writeText("stop") + killIfNeeded(first) + killIfNeeded(second) + } + } finally { + dir.deleteRecursively() + } + } + + @Test + fun `published 2_0_3 and this branch can load WinTray at the same time`() { + if (!isWindows) return + + val java = currentJava() + val classpath = System.getProperty("java.class.path") + val published = publishedWinTray() + val dir = Files.createTempDirectory("cnt-534-mixed-").toFile() + val stopOld = File(dir, "stop-old") + val stopNew = File(dir, "stop-new") + val oldResources = File(dir, "old-resources") + File(oldResources, "composetray/native/win32-x86-64/WinTray.dll").apply { + parentFile.mkdirs() + published.copyTo(this, overwrite = true) + } + try { + val old = + ProcessBuilder( + java, + "-cp", + oldResources.absolutePath + File.pathSeparator + classpath, + Issue534OldVersionMain::class.java.name, + "--hold", + "--stop-file=${stopOld.absolutePath}", + ).redirectErrorStream(true).start() + try { + check(waitForLine(old, "HOLDING")) { "2.0.3 process did not reach HOLDING" } + + val current = + ProcessBuilder( + java, + "-cp", + classpath, + Issue534ReproMain::class.java.name, + "--hold", + "--stop-file=${stopNew.absolutePath}", + ).redirectErrorStream(true).start() + try { + check(waitForLine(current, "HOLDING")) { "current-branch process did not reach HOLDING" } + assertTrue(old.isAlive, "2.0.3 process died after the current branch loaded") + assertTrue(current.isAlive, "current-branch process died after load") + } finally { + stopNew.writeText("stop") + killIfNeeded(current) + } + } finally { + stopOld.writeText("stop") + killIfNeeded(old) + } + } finally { + dir.deleteRecursively() + } + } + + private fun currentJava(): String = + ProcessHandle.current().info().command().orElseThrow { + IllegalStateException("Cannot resolve the current java executable") + } + + private fun killIfNeeded(process: Process) { + if (!process.waitFor(5, TimeUnit.SECONDS)) { + ProcessBuilder("taskkill", "/F", "/T", "/PID", process.pid().toString()) + .inheritIO() + .start() + .waitFor(5, TimeUnit.SECONDS) + process.waitFor(5, TimeUnit.SECONDS) + } + } + + private fun publishedWinTray(): File { + val published = File("src/jvmMain/resources/composetray/native/win32-x64/WinTray.dll") + require(published.isFile) { "missing published WinTray.dll at $published" } + return published + } + + private fun waitForLine( + process: Process, + token: String, + ): Boolean { + val reader = process.inputStream.bufferedReader() + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30) + while (System.nanoTime() < deadline && process.isAlive) { + if (reader.ready()) { + val line = reader.readLine() ?: break + if (line.contains(token)) return true + } else { + Thread.sleep(50) + } + } + return false + } + + private val isWindows: Boolean + get() = System.getProperty("os.name").orEmpty().lowercase().contains("win") +} diff --git a/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/NativeLibraryLoaderCacheTest.kt b/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/NativeLibraryLoaderCacheTest.kt deleted file mode 100644 index be7c405..0000000 --- a/src/jvmTest/kotlin/dev/nucleusframework/composenativetray/utils/NativeLibraryLoaderCacheTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package dev.nucleusframework.composenativetray.utils - -import java.io.File -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * Regression test for issue #401. - * - * The AVX/SSE2 builds of WinTray.dll are byte-for-byte the same size (23552 bytes) - * but differ in content. The old loader validated its persistent cache by size only, - * so after upgrading the user kept running the stale AVX library and the crash - * survived the fix. The cache must be validated by content hash instead. - */ -class NativeLibraryLoaderCacheTest { - private fun tempFileWith(bytes: ByteArray): File = - File.createTempFile("composetray-cache-test", ".bin").apply { - deleteOnExit() - writeBytes(bytes) - } - - @Test - fun `stale cache of identical size but different content is rejected`() { - // Two payloads with the SAME size but DIFFERENT content — the exact - // shape of the AVX vs SSE2 WinTray.dll pair. - val cachedBytes = ByteArray(23552) { 0x01 } - val resourceBytes = ByteArray(23552) { 0x02 } - require(cachedBytes.size == resourceBytes.size) - - val cachedFile = tempFileWith(cachedBytes) - val resourceHash = resourceBytes.inputStream().use { it.sha256() } - - // A size-only check would have accepted this stale file; the hash check rejects it. - assertFalse( - NativeLibraryLoader.isCacheUpToDate(cachedFile, resourceHash), - "Same-size but different-content cache must be treated as stale and re-extracted", - ) - } - - @Test - fun `matching content is served from cache`() { - val bytes = ByteArray(23552) { (it % 256).toByte() } - val cachedFile = tempFileWith(bytes) - val resourceHash = bytes.inputStream().use { it.sha256() } - - assertTrue( - NativeLibraryLoader.isCacheUpToDate(cachedFile, resourceHash), - "Identical content must be reused from cache", - ) - } - - @Test - fun `missing cache file is not up to date`() { - val missing = File.createTempFile("composetray-cache-test", ".bin").apply { delete() } - assertFalse(NativeLibraryLoader.isCacheUpToDate(missing, "anyhash")) - } -} diff --git a/src/native/linux/build.sh b/src/native/linux/build.sh index 24681bd..b6908bb 100755 --- a/src/native/linux/build.sh +++ b/src/native/linux/build.sh @@ -42,7 +42,7 @@ SDBUS_LIBS=$(pkg-config --libs libsystemd) # Detect host architecture UNAME_ARCH="$(uname -m)" case "$UNAME_ARCH" in - x86_64) ARCH="x86-64" ;; + x86_64) ARCH="x64" ;; aarch64) ARCH="aarch64" ;; *) echo "ERROR: Unsupported architecture: $UNAME_ARCH"; exit 1 ;; esac @@ -82,13 +82,15 @@ strip --strip-unneeded "$OUTPUT_DIR/$PLATFORM_DIR/libLinuxTray.so" # Clean up object files rm -f "$SCRIPT_DIR/sni.o" "$SCRIPT_DIR/jni_bridge.o" -# Invalidate runtime cache (NativeLibraryLoader validates by size only, -# so a same-size rebuild would serve the stale cached copy) -CACHE_FILE="$HOME/.cache/composetray/native/$PLATFORM_DIR/libLinuxTray.so" -if [ -f "$CACHE_FILE" ]; then - rm -f "$CACHE_FILE" - echo "Cleared cached library: $CACHE_FILE" -fi +# Content-addressed Nucleus cache: evict every copy of this library so a +# same-size rebuild cannot keep serving the stale fingerprint directory. +CACHE_BASE="${XDG_CACHE_HOME:-$HOME/.cache}" +for CACHE_DIR in "$CACHE_BASE/nucleus/native" "$CACHE_BASE/composetray/native"; do + if [ -d "$CACHE_DIR" ]; then + find "$CACHE_DIR" -name "libLinuxTray.so" -delete + echo "Cleared cached library under: $CACHE_DIR" + fi +done echo "Build completed: $OUTPUT_DIR/$PLATFORM_DIR/libLinuxTray.so" ls -lh "$OUTPUT_DIR/$PLATFORM_DIR/libLinuxTray.so" diff --git a/src/native/macos/build.sh b/src/native/macos/build.sh index b6fff45..2389c9d 100755 --- a/src/native/macos/build.sh +++ b/src/native/macos/build.sh @@ -28,7 +28,7 @@ if [ ! -f "$JNI_INCLUDE/jni.h" ]; then fi mkdir -p "$OUTPUT_DIR/darwin-aarch64" -mkdir -p "$OUTPUT_DIR/darwin-x86-64" +mkdir -p "$OUTPUT_DIR/darwin-x64" build_arch() { local ARCH=$1 @@ -66,15 +66,14 @@ build_arch() { } 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" +build_arch "x86_64" "x86_64-apple-macosx11.0" "$OUTPUT_DIR/darwin-x64" -# Invalidate runtime cache (NativeLibraryLoader validates by size only, -# so a same-size rebuild would serve the stale cached copy) -for PLATFORM_DIR in darwin-aarch64 darwin-x86-64; do - CACHE_FILE="$HOME/.cache/composetray/native/$PLATFORM_DIR/libMacTray.dylib" - if [ -f "$CACHE_FILE" ]; then - rm -f "$CACHE_FILE" - echo "Cleared cached library: $CACHE_FILE" +# Content-addressed Nucleus cache: evict every copy of this library so a +# same-size rebuild cannot keep serving the stale fingerprint directory. +for CACHE_DIR in "$HOME/Library/Caches/nucleus/native" "$HOME/.cache/nucleus/native" "$HOME/.cache/composetray/native"; do + if [ -d "$CACHE_DIR" ]; then + find "$CACHE_DIR" -name "libMacTray.dylib" -delete + echo "Cleared cached library under: $CACHE_DIR" fi done diff --git a/src/native/windows/CMakeLists.txt b/src/native/windows/CMakeLists.txt index 1428fce..eb10ca3 100644 --- a/src/native/windows/CMakeLists.txt +++ b/src/native/windows/CMakeLists.txt @@ -16,7 +16,7 @@ endif() # Check target architecture if(CMAKE_GENERATOR_PLATFORM STREQUAL "x64" OR CMAKE_GENERATOR_PLATFORM STREQUAL "") set(TARGET_ARCH "x64") - set(OUTPUT_DIR "${BASE_OUTPUT_DIR}/win32-x86-64") + set(OUTPUT_DIR "${BASE_OUTPUT_DIR}/win32-x64") # Do NOT add /arch:AVX, /arch:AVX2 or higher here. The default x64 baseline is # SSE2, which every x86-64 CPU supports. Enabling AVX makes MSVC emit # VEX-encoded instructions (e.g. VPXOR) that raise EXCEPTION_ILLEGAL_INSTRUCTION @@ -25,7 +25,7 @@ if(CMAKE_GENERATOR_PLATFORM STREQUAL "x64" OR CMAKE_GENERATOR_PLATFORM STREQUAL # is found, so a reintroduced /arch flag will be caught. elseif(CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64") set(TARGET_ARCH "ARM64") - set(OUTPUT_DIR "${BASE_OUTPUT_DIR}/win32-arm64") + set(OUTPUT_DIR "${BASE_OUTPUT_DIR}/win32-aarch64") else() message(FATAL_ERROR "Unsupported architecture: ${CMAKE_GENERATOR_PLATFORM}") endif()