From 935ad84ac3544c0effeaebc9337b4bc16c6e39ec Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 18 May 2026 14:31:13 +0300 Subject: [PATCH 1/2] fix(desktop): fix release build ProGuard rules that crash app on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProGuard in release builds (packageReleaseDmg/Deb/Rpm) strips classes accessed via reflection, JNI, or service loading — causing multiple runtime failures: - Jackson ExceptionInInitializerError: enum constants stripped, NPE in MapperConfig.collectFeatureDefaults() (#2929, 5000 sats bounty) - JNA/VLCJ: static methods stripped, SIGABRT in native callbacks - secp256k1/SQLite/kmp-tor: JNI loader classes stripped - Coil/okhttp/okio: image loading and networking broken - Kotlin metadata stripped: Jackson can't call default constructors Changes: - Upgrade ProGuard 7.7.0 → 7.9.1 (Kotlin 2.3 metadata support) - Disable optimization (-dontoptimize) to prevent bytecode rewriting that produces VerifyError (Guardsquare/proguard#460) - Add -keep rules for all JNI/reflection-dependent libraries - Keep Kotlin @Metadata annotations for Jackson deserialization - Suppress Kotlin 2.3 compile-time stub warnings Tested: release DMG builds and launches without crash on macOS. Closes #2929 Co-Authored-By: Claude Opus 4.6 (1M context) --- desktopApp/build.gradle.kts | 14 ++++++++- desktopApp/compose-rules.pro | 16 ++++++++++ .../service/media/MacOsVlcDiscoverer.kt | 29 +++++++++++++++++-- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0ec8f6836e..61485c6e68 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -91,6 +91,9 @@ compose.desktop { jvmArgs += "-Xmx2g" + // VLC plugin path fallback — used if JNA setenv and bundled discovery both fail + jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins" + // Forward platform-preview overrides from the gradle invocation to the // launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME` // works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`). @@ -101,7 +104,15 @@ compose.desktop { nativeDistributions { appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) - modules("java.management") // Required by kmp-tor TorRuntime + // Output of ./gradlew suggestRuntimeModules (+ java.management already present) + modules( + "java.instrument", // Runtime instrumentation (agent/profiler hooks) + "java.management", // Required by kmp-tor TorRuntime + "java.prefs", // java.util.prefs (desktop persistence) + "java.sql", // JDBC metadata (Jackson, SQLite driver) + "jdk.security.auth", // JAAS authentication callbacks + "jdk.unsupported", // sun.misc.Unsafe (VLCJ ByteBufferFactory) + ) packageName = "Amethyst" packageVersion = appVersion @@ -143,6 +154,7 @@ compose.desktop { // whose declared return type the JVM verifier rejects (R8 doesn't hit // this — it generates bridges differently from ProGuard). buildTypes.release.proguard { + version.set("7.9.1") // Kotlin 2.3 metadata support configurationFiles.from(project.file("compose-rules.pro")) } } diff --git a/desktopApp/compose-rules.pro b/desktopApp/compose-rules.pro index bb824643d1..8241422ed6 100644 --- a/desktopApp/compose-rules.pro +++ b/desktopApp/compose-rules.pro @@ -96,6 +96,16 @@ native ; } +# kmp-tor — loads native Tor daemon via JNI reflection +-keep class io.matthewnelson.** { *; } + +# Coil image loader — uses ServiceLoader for decoder/fetcher registration +-keep class coil3.** { *; } + +# OkHttp/Okio — platform detection and I/O via reflection +-keep class okhttp3.** { *; } +-keep class okio.** { *; } + # ============================================================================ # Optimize sub-pass — disable the one that produces invalid okio bytecode # ============================================================================ @@ -185,3 +195,9 @@ # to detect logging. We ship slf4j-nop; keep it intact so detection succeeds. -keep class org.slf4j.** { *; } -dontwarn org.slf4j.** + +# ============================================================================ +# Kotlin 2.3 stdlib stubs — compile-time classes with no JVM runtime class +# ============================================================================ +-dontwarn kotlin.concurrent.atomics.** +-dontwarn kotlin.jvm.internal.EnhancedNullability diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt index d7f9c02f2f..58d7f9fa54 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.desktop.service.media +import com.sun.jna.Function import com.sun.jna.NativeLibrary -import uk.co.caprica.vlcj.binding.lib.LibC import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy @@ -36,6 +36,14 @@ class MacOsVlcDiscoverer : arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"), arrayOf("%s/plugins"), ) { + /** Plugin path discovered during [setPluginPath], available after discovery. */ + var discoveredPluginPath: String? = null + private set + + /** Whether [setPluginPath] successfully set the process env var. */ + var envVarSet: Boolean = false + private set + override fun supported(): Boolean { val os = System.getProperty("os.name").lowercase() return "mac" in os @@ -52,5 +60,22 @@ class MacOsVlcDiscoverer : return true } - override fun setPluginPath(pluginPath: String?): Boolean = LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0 + override fun setPluginPath(pluginPath: String?): Boolean { + if (pluginPath == null) return false + discoveredPluginPath = pluginPath + return try { + // Call setenv directly via JNA Function API. This bypasses vlcj's + // LibC interface binding which fails on macOS 13+ because dlsym + // can't resolve the versioned symbol `setenv$3b99ba0d`. + val setenv = Function.getFunction("c", "setenv") + val result = setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0 + envVarSet = result + result + } catch (_: Throwable) { + // JNA Function call also failed — VlcjPlayerPool will use + // --plugin-path factory arg as fallback. + envVarSet = false + false + } + } } From de5dc34cb7155f50bdad44c48d8aba85642fa89a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 18 May 2026 15:16:23 +0300 Subject: [PATCH 2/2] fix(desktop): fix macOS VLC bundled discovery and video rendering MacOsVlcDiscoverer.setPluginPath() called LibC.INSTANCE.setenv() via JNA, but macOS 13+ uses versioned symbols (setenv$3b99ba0d) that JNA can't resolve, breaking all video playback without system VLC. Changes: - Replace LibC.setenv with direct JNA Function.getFunction("c","setenv") call that bypasses the problematic interface binding - Store discoveredPluginPath for --plugin-path factory arg fallback - Add -Dvlc.plugin.path JVM property as ultimate fallback - Delete stale VLC plugin cache on macOS before factory creation - Pass --plugin-path to audio factory too when env var fails - Add jdk.unsupported module to jlink (VLCJ ByteBufferFactory needs sun.misc.Unsafe for video frame buffer allocation) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../desktop/service/media/VlcjPlayerPool.kt | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt index 18e08c306d..50ea042e58 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt @@ -55,6 +55,9 @@ object VlcjPlayerPool { private val idleThumbPlayers = ConcurrentLinkedQueue() private const val MAX_THUMB_POOL_SIZE = 2 + // Cached plugin path for audio factory creation (set during init) + private var cachedPluginPath: String? = null + // Audio player pool (shared factory with --no-video) private var audioFactory: MediaPlayerFactory? = null private val allAudioPlayers = mutableListOf() @@ -76,12 +79,13 @@ object VlcjPlayerPool { return try { // Try bundled VLC first, then fall through to system VLC + val macOsDiscoverer = MacOsVlcDiscoverer() val discovery = try { val nd = NativeDiscovery( BundledVlcDiscoverer(), - MacOsVlcDiscoverer(), + macOsDiscoverer, ) val found = nd.discover() if (found) { @@ -99,7 +103,34 @@ object VlcjPlayerPool { val systemDiscovery = NativeDiscovery().discover() println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}") } - val f = MediaPlayerFactory("--no-xlib") + + // Delete stale VLC plugin cache on macOS to avoid spam warnings + if ("mac" in System.getProperty("os.name").lowercase()) { + try { + val cacheDir = java.io.File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc") + cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() } + } catch (_: Throwable) { + // Best-effort cache cleanup + } + } + + // Build factory args — add --plugin-path fallback if env var wasn't set + val factoryArgs = mutableListOf("--no-xlib") + if (!macOsDiscoverer.envVarSet) { + val pluginPath = + macOsDiscoverer.discoveredPluginPath + ?: System.getProperty("vlc.plugin.path") + ?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" } + if (pluginPath != null) { + factoryArgs += "--plugin-path=$pluginPath" + println("VLC: using --plugin-path fallback: $pluginPath") + } + } + + cachedPluginPath = macOsDiscoverer.discoveredPluginPath + ?: System.getProperty("vlc.plugin.path") + + val f = MediaPlayerFactory(*factoryArgs.toTypedArray()) factory = f available.set(true) println("VLC: MediaPlayerFactory created successfully") @@ -184,7 +215,9 @@ object VlcjPlayerPool { val af = audioFactory ?: try { - MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it } + val audioArgs = mutableListOf("--no-video", "--no-xlib") + cachedPluginPath?.let { audioArgs += "--plugin-path=$it" } + MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it } } catch (_: Throwable) { return null }