Files
amethyst/desktopApp/build.gradle.kts
T
Claude 61c8768ad1 Merge main into claude/intelligent-babbage-23komv
Resolves the AppShellLayout conflict with main's resizable multi-pane
shell: the deck takes the notification panel's place, so no width is
kept for the panel while a deck is shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
2026-10-09 20:15:41 +00:00

554 lines
28 KiB
Kotlin

import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask
import java.net.URI
import java.nio.file.FileSystems
import java.nio.file.Files
import java.util.zip.ZipFile
plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.jetbrainsComposeCompiler)
}
// RPM rejects dashes in version strings — replace with tilde (~) which RPM uses
// for prerelease ordering: 1.08.0~rc1 < 1.08.0 per RPM version comparison rules.
val appVersion: String = project.version.toString()
sourceSets {
main {
kotlin.srcDir("src/jvmMain/kotlin")
resources.srcDir("src/jvmMain/resources")
}
test {
kotlin.srcDir("src/jvmTest/kotlin")
resources.srcDir("src/jvmTest/resources")
}
}
kotlin {
jvmToolchain(21)
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.components.resources)
// Quartz Nostr library (will use JVM target)
implementation(project(":quartz"))
// Commons library (headless) + its Compose UI half
implementation(project(":commons"))
implementation(project(":commonsUI"))
// Marmot's raw-QUIC transport for agent text stream previews, as on Android
implementation(project(":marmotQuic"))
implementation(project(":quic"))
// The shared preference files (DataStore), as commons opens them
implementation(libs.androidx.datastore.preferences)
// Lifecycle ViewModel (needed to access ViewModel supertype from commons)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Coroutines
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.swing)
// Networking
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
// JSON
implementation(libs.jackson.module.kotlin)
// Image loading (Coil3 — explicit because commons uses implementation, not api)
implementation(libs.coil.compose)
implementation(libs.coil.okhttp)
implementation(libs.coil.svg)
// Unicode emoji picker (Kodein Emoji.kt — MIT; offline system-font rendering on desktop)
implementation(libs.kodein.emoji.compose.m3)
// Video / audio playback — MIT, OS-native backends (MF / AVFoundation / GStreamer)
// composemediaplayer 0.10.0 leaks kotlinx-coroutines-test as a *runtime* dependency
// in its published POM. That jar ships a META-INF/services registration for
// kotlinx.coroutines.CoroutineExceptionHandler -> ExceptionCollectorAsService; the
// release ProGuard pass strips the (unreferenced) provider class but keeps the
// services file, so the packaged dmg crashes at startup with a
// ServiceConfigurationError the first time the coroutine exception handler loads.
// It is test-only code that must never be on the production classpath — exclude it.
implementation(libs.composemediaplayer) {
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-test")
}
// Thumbnail extraction — JCodec (pure-Java H.264). LGPL FFmpeg subprocess
// for non-H.264 / HLS fallback is invoked via plain ProcessBuilder; no
// wrapper library needed (see VideoThumbnailCache.runFfmpegToImage).
implementation(libs.jcodec)
implementation(libs.jcodec.javase)
// EXIF stripping (lossless)
implementation(libs.commons.imaging)
// Collections
implementation(libs.kotlinx.collections.immutable)
implementation(libs.androidx.collection)
// Tor daemon (desktop embedded via kmp-tor)
implementation(libs.kmp.tor.runtime)
implementation(libs.kmp.tor.resource.exec.tor)
// SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps
implementation(libs.slf4j.nop)
// QR code generation (ZXing core)
implementation(libs.zxing)
// Testing
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.okhttp)
// Compose UI testing (createComposeRule / onNodeWithText / etc.)
testImplementation(libs.jetbrains.compose.ui.test.junit4)
}
// Previews another OS's look on this machine: `./gradlew :desktopApp:run -Damethyst.platform=MACOS
// -Damethyst.appearance=light`. Only the dev `run` gets them, never a packaged app.
// Added at execution: the Compose plugin sets the task's jvmArgs after this script configures it.
tasks.withType<JavaExec>().matching { it.name == "run" }.configureEach {
val overrides = listOf("amethyst.platform", "amethyst.appearance", "amethyst.accent")
val dockIcon = project.file("src/jvmMain/resources/icon.icns")
doFirst {
overrides.forEach { key -> System.getProperty(key)?.let { jvmArgs("-D$key=$it") } }
// macOS: names the Dock's entry for the plain JVM `run` starts (the packaged app takes its
// name from its Info.plist). The app switcher still says "java": it reads the name of the
// bundle the binary sits in, and the JDK's java is its own bundle.
if (System.getProperty("os.name").startsWith("Mac")) {
jvmArgs("-Xdock:name=Amethyst", "-Xdock:icon=${dockIcon.absolutePath}")
}
}
}
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.app.AmethystDesktopKt"
jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED"
// Lets the app name its X11 windows "Amethyst" (WM_CLASS), which the Linux desktop entries
// declare as StartupWMClass. A no-op on macOS and Windows.
jvmArgs += "--add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED"
jvmArgs += "-Xmx2g"
// The shared app reads its version from here (there is no BuildConfig on desktop).
jvmArgs += "-Damethyst.version=$appVersion"
// Forward overrides from the gradle invocation to the launched app's JVM, so
// `./gradlew :desktopApp:run -Damethyst.dataDir=/tmp/amy` keeps a test profile apart.
listOf("amethyst.dataDir", "amethyst.debug").forEach { key ->
System.getProperty(key)?.let { jvmArgs += "-D$key=$it" }
}
nativeDistributions {
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
// 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 (secp256k1-kmp-jni-jvm, JNA)
"jdk.localedata", // CLDR data for the shared app's date and number formatters
"jdk.httpserver", // Loopback relay that streams Tor-routed video through OkHttp
)
packageName = "Amethyst"
packageVersion = appVersion
description = "Nostr client for desktop"
vendor = "Amethyst Contributors"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
// --- Developer ID code signing + notarization ---
// Required for Homebrew's main cask (unsigned casks rejected after
// 2026-09-01) and to clear macOS Gatekeeper without the right-click
// dance. Gated on the signing-identity env var so local dev builds
// and PR CI keep producing plain UNSIGNED DMGs exactly as before —
// signing only kicks in when the release workflow exports these
// (which it does only when the Apple secrets are present):
//
// AMETHYST_MAC_SIGN_IDENTITY "Developer ID Application: NAME (TEAMID)"
// AMETHYST_NOTARY_APPLE_ID Apple ID email of the notary account
// AMETHYST_NOTARY_PASSWORD app-specific password for that Apple ID
// AMETHYST_NOTARY_TEAM_ID 10-char Apple Developer Team ID
//
// The Developer ID Application certificate must already be in the
// build host's keychain (CI imports it from a base64 .p12 secret).
// Compose ships default hardened-runtime entitlements that permit
// the JVM's JIT, so no custom entitlements file is needed.
val macSignIdentity = System.getenv("AMETHYST_MAC_SIGN_IDENTITY")
if (!macSignIdentity.isNullOrBlank()) {
signing {
sign.set(true)
identity.set(macSignIdentity)
// Compose's MacSigner maps the identity to a certificate via
// `security find-certificate`, which doesn't reliably resolve
// the CI-imported cert through the keychain search list (fails
// with "Could not find certificate ... in keychain []"). When
// the workflow exports the throwaway keychain path, point the
// lookup straight at it. Empty/unset on local builds — Compose
// then falls back to the default search list as before.
System.getenv("AMETHYST_MAC_SIGN_KEYCHAIN")
?.takeIf { it.isNotBlank() }
?.let { keychain.set(it) }
}
notarization {
appleID.set(System.getenv("AMETHYST_NOTARY_APPLE_ID"))
password.set(System.getenv("AMETHYST_NOTARY_PASSWORD"))
teamID.set(System.getenv("AMETHYST_NOTARY_TEAM_ID"))
}
}
}
windows {
iconFile.set(project.file("src/jvmMain/resources/icon.ico"))
menuGroup = "Amethyst"
upgradeUuid = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
// AUMID persistence for Windows Toast notifications — without a
// Start Menu shortcut the AUMID isn't registered and toasts
// disappear from Windows Settings → Notifications after reboot.
menu = true
shortcut = true
}
linux {
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
menuGroup = "Network"
appCategory = "Network"
debMaintainer = "vitor@vitorpamplona.com"
// SPDX compound expression. Bundled components:
// MIT — Amethyst + kdroidFilter ComposeMediaPlayer
// LGPL-2.1-or-later — FFmpeg (LGPL build, bundled per OS for thumbnail fallback) +
// GStreamer (Linux runtime dep, system-installed)
// BSD-2-Clause — JCodec
// Apache-2.0 — Jaffree + many transitive Java libraries
rpmLicenseType = "MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0"
// RPM version: replace dashes with tilde (1.08.0~rc1 < 1.08.0 per RPM ordering).
rpmPackageVersion = appVersion.replace("-", "~")
}
}
// Compose Multiplatform 1.11.0 wired ProGuard 7.7.0 into the release
// build. The Android (mobile) module already solved the same class of
// problems with `-dontobfuscate` plus global `-keepnames` / `-keep enum`
// rules (see `amethyst/proguard-rules.pro`). We mirror that strategy in
// `compose-rules.pro` so the desktop release survives JNI callbacks
// (secp256k1-kmp, sqlite-bundled, jkeychain, kdroidFilter native)
// and reflection-heavy libraries (Jackson, JNA) without renaming.
//
// Shrink and optimize stay ON. One ProGuard optimize sub-pass is
// disabled in `compose-rules.pro` to avoid a generated okio bridge
// 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"))
}
}
}
// --- GStreamer in the .rpm (Linux) ---
//
// The media player (kdroidFilter) drives the system GStreamer from a native library it unpacks
// from its jar when the first video plays, so neither jpackage nor rpmbuild sees it and the .rpm
// required no GStreamer: on a system without it every video said "Failed to create native
// player". Neither jpackage nor the Compose DSL can add a Requires. jpackage does take its spec
// from --resource-dir when a <package>.spec is there, but the Compose task empties that directory
// right before it runs jpackage, and deletes its inputs after. So the rpm tasks finish by
// repackaging the matching app image (createDistributable's, the same content) with the
// task's own package options and a resource dir holding jpackage's template, read from the JDK it
// packages with, plus these lines. The .deb gets the same packages from
// scripts/add-deb-gstreamer-deps.sh in CI. The same resource dir also adds StartupWMClass to the
// desktop entry (scripts/add-deb-wm-class.sh does that for the .deb).
//
// base: playbin, appsink, volume; good: HTTP, MP4, HLS, audio output; libav/openh264: H.264 and
// AAC decoding. The boolean forms take Fedora's or openSUSE's name; the decoders are weak
// deps, since a distribution may keep them in a separate repository.
val rpmGStreamerDeps =
"""
Requires: (gstreamer1-plugins-base or gstreamer-plugins-base)
Requires: (gstreamer1-plugins-good or gstreamer-plugins-good)
Recommends: (gstreamer1-plugin-libav or gstreamer-plugins-libav)
Recommends: gstreamer1-plugin-openh264
""".trimIndent()
// What an app image already holds; jpackage refuses these next to --app-image.
val appImageOptions =
setOf(
"--input", "--runtime-image", "--main-jar", "--main-class", "--java-options", "--arguments",
"--resource-dir", "--app-content", "--add-launcher", "--add-modules", "--module", "--module-path",
"--jlink-options",
)
listOf("packageRpm" to "main", "packageReleaseRpm" to "main-release").forEach { (rpmTask, buildType) ->
tasks.withType<AbstractJPackageTask>().matching { it.name == rpmTask }.configureEach {
dependsOn(if (buildType == "main") "createDistributable" else "createReleaseDistributable")
val packagingJdk = javaHome
val appName = packageName
val rpmPackageName = linuxPackageName.orElse(packageName.map { it.lowercase() })
val composeArgs = layout.buildDirectory.file("compose/tmp/$rpmTask.args.txt")
val ourArgs = layout.buildDirectory.file("compose/tmp/$rpmTask.gstreamer.args.txt")
val specDir = layout.buildDirectory.dir("compose/tmp/$rpmTask-rpm-resources")
val appImages = layout.buildDirectory.dir("compose/binaries/$buildType/app")
val deps = rpmGStreamerDeps
val skip = appImageOptions
doLast {
val jdk = packagingJdk.get()
val (template, desktopTemplate) =
FileSystems.newFileSystem(URI.create("jrt:/"), mapOf("java.home" to jdk)).use { jrt ->
val resources = "/modules/jdk.jpackage/jdk/jpackage/internal/resources"
Files.readString(jrt.getPath("$resources/template.spec")) to
Files.readString(jrt.getPath("$resources/template.desktop"))
}
val anchor = Regex("(?m)^Autoreq: 0$")
check(anchor.containsMatchIn(template)) {
"jpackage's spec template has no 'Autoreq: 0' line to add the GStreamer Requires after"
}
val resources = specDir.get().asFile.apply { deleteRecursively(); mkdirs() }
File(resources, "${rpmPackageName.get()}.spec").writeText(anchor.replaceFirst(template, "Autoreq: 0\n$deps"))
// Docks and app switchers match the window's WM_CLASS ("Amethyst", set in AmethystDesktop.kt)
// to the desktop entry's StartupWMClass, which jpackage's template lacks. jpackage takes
// <app name>.desktop from the resource dir in its place and still fills in its fields.
File(resources, "${appName.get()}.desktop").writeText(desktopTemplate.trimEnd() + "\nStartupWMClass=Amethyst\n")
// The task's own options, one per line with quoted values, minus the app-image ones.
val lines = composeArgs.get().asFile.readLines()
val args = mutableListOf<String>()
var i = 0
while (i < lines.size) {
val takesValue = i + 1 < lines.size && !lines[i + 1].startsWith("--")
if (lines[i] !in skip) {
args += lines[i]
if (takesValue) args += lines[i + 1]
}
i += if (takesValue) 2 else 1
}
val appImage = appImages.get().dir(appName.get()).asFile
check(appImage.isDirectory) { "No app image at $appImage to repackage the .rpm from" }
args += listOf("--app-image", "\"${appImage.absolutePath}\"", "--resource-dir", "\"${resources.absolutePath}\"")
val dest = File(args[args.indexOf("--dest") + 1].trim('"'))
dest.listFiles { f -> f.name.endsWith(".rpm") }?.forEach { it.delete() }
val argFile = ourArgs.get().asFile.apply { writeText(args.joinToString("\n")) }
val process =
ProcessBuilder(File(jdk, "bin/jpackage").absolutePath, "@${argFile.absolutePath}")
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
check(process.waitFor() == 0) { "jpackage could not repackage the .rpm with its GStreamer dependencies:\n$output" }
logger.lifecycle("Repackaged ${dest.listFiles { f -> f.name.endsWith(".rpm") }?.joinToString { it.name }} with its GStreamer dependencies")
}
}
}
// --- AppImage packaging (Linux) ---
//
// Compose Multiplatform's TargetFormat.AppImage is known-broken in 1.10.x (CMP-7101).
// Instead: wrap `createReleaseDistributable` output with `appimagetool`, which
// just packages an AppDir as-is. We deliberately avoid `linuxdeploy` here —
// linuxdeploy auto-walks every binary in the AppDir with ldd to bundle deps,
// but jpackage already ships a self-contained tree we don't want it touching
// (the bundled JRE has libjvm.so under usr/lib/runtime/lib/server/ while sibling
// libs use $ORIGIN RPATH — ldd can't resolve without help).
// appimagetool sidesteps that — it only embeds the AppDir into a SquashFS,
// runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH at launch.
//
// kdroidFilter (video/audio) links against system GStreamer at runtime — the
// AppImage does not bundle GStreamer; the host system must have it installed.
//
// Build inputs live in desktopApp/packaging/appimage/:
// - AppRun shell launcher
// - amethyst.desktop XDG desktop entry
// - amethyst.png 512x512 icon
//
// appimagetool binary is fetched by CI (SHA-verified) into
// desktopApp/packaging/appimage/ as appimagetool-<arch>.AppImage.
// The arch is selected at task-execution time from the host JVM's os.arch, so
// the same task builds the correct AppImage on both x86_64 and aarch64 hosts.
// BUILDING.md documents local-dev fetch.
val createReleaseAppImage = tasks.register<Exec>("createReleaseAppImage") {
group = "compose desktop"
description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool."
dependsOn("createReleaseDistributable")
// AppImage's ARCH env accepts the Linux kernel arch names: x86_64 / aarch64
// / armhf / i686. jpackage produces host-native binaries, so mirror the
// host JVM arch. Do not read the property inside doFirst — it needs to be
// resolved at configuration time so outputs.file() below is stable.
val hostArch = when (val a = System.getProperty("os.arch").lowercase()) {
"amd64", "x86_64" -> "x86_64"
"aarch64", "arm64" -> "aarch64"
else -> a
}
val distDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Amethyst")
val appDir = layout.buildDirectory.dir("appimage/Amethyst.AppDir")
val outFile = layout.buildDirectory.file("appimage/Amethyst-$appVersion-$hostArch.AppImage")
val toolRoot = layout.projectDirectory.dir("packaging/appimage")
val appimagetool = toolRoot.file("appimagetool-$hostArch.AppImage")
inputs.dir(distDir)
inputs.dir(toolRoot)
outputs.file(outFile)
doFirst {
val dir = appDir.get().asFile
dir.deleteRecursively()
dir.mkdirs()
copy {
from(distDir) { into("usr") }
from(toolRoot.file("AppRun")) {
rename { "AppRun" }
filePermissions { unix("0755") }
}
from(toolRoot.file("amethyst.desktop"))
from(toolRoot.file("amethyst.png"))
into(dir)
}
// DirIcon is used by desktop integrations (file managers, AppImageLauncher)
val dirIcon = File(dir, ".DirIcon")
if (dirIcon.exists()) dirIcon.delete()
Files.createSymbolicLink(dirIcon.toPath(), File("amethyst.png").toPath())
if (!appimagetool.asFile.canExecute()) {
appimagetool.asFile.setExecutable(true)
}
}
commandLine(
appimagetool.asFile.absolutePath,
appDir.get().asFile.absolutePath,
outFile.get().asFile.absolutePath,
)
environment("ARCH", hostArch)
// Bypass FUSE requirement on CI runners (ubuntu-latest lacks libfuse.so.2).
// AppImage standard env var: extracts + runs without mounting.
environment("APPIMAGE_EXTRACT_AND_RUN", "1")
}
// ============================================================================
// Native lib regression guard
// ============================================================================
// `pt.davidafsilva.apple:jkeychain` ships its `osxkeychain.so` native library
// as a resource at the JAR root, and `OSXKeychain.loadSharedObject()` reaches
// it with `getResourceAsStream("/osxkeychain.so")`. If a future ProGuard
// upgrade, dep swap, or build-config change strips this resource from the
// release distributable, every macOS user is forced back to the login screen
// on every cold boot (the Keychain backend can't load, `SecureKeyStorage`
// falls back to its password-prompted encrypted file, which never prompts
// in a GUI cold boot → keys silently null → bunker / nsec / NWC unrecoverable).
//
// As of the current build the resource DOES survive ProGuard (it ships in the
// proguarded jkeychain-1.1.0-*.jar with all 117 KB intact), so this task is
// a regression guard, not a workaround. It's wired onto every release task so
// it fails the build immediately if the .so disappears.
val verifyJkeychainNativeSurvivesProguard = tasks.register("verifyJkeychainNativeSurvivesProguard") {
description = "Fail the release build if osxkeychain.so is stripped from proguarded output (would break macOS Keychain at runtime)"
group = "verification"
dependsOn("proguardReleaseJars")
val proguardDir = layout.buildDirectory.dir("compose/tmp/main-release/proguard")
inputs.dir(proguardDir).withPropertyName("proguardOutput").withPathSensitivity(PathSensitivity.RELATIVE)
val marker = layout.buildDirectory.file("verify-jkeychain-native.ok")
outputs.file(marker)
doLast {
val dir = proguardDir.get().asFile
require(dir.isDirectory) { "ProGuard output dir missing: $dir" }
val jars = dir.listFiles { f -> f.extension == "jar" } ?: emptyArray()
require(jars.isNotEmpty()) { "No proguarded jars under $dir" }
val foundIn = jars.firstOrNull { jar ->
val zip = ZipFile(jar)
try {
val entries = zip.entries()
var found = false
while (entries.hasMoreElements()) {
if (entries.nextElement().name == "osxkeychain.so") {
found = true
break
}
}
found
} finally {
zip.close()
}
}
require(foundIn != null) {
"REGRESSION: osxkeychain.so missing from every proguarded jar in $dir.\n" +
"OSXKeychain.loadSharedObject() calls getResourceAsStream(\"/osxkeychain.so\") — if this\n" +
"resource is not at classpath root in the release distributable, every macOS user is\n" +
"forced to re-log-in on every cold boot of the release DMG."
}
marker.get().asFile.writeText("verified osxkeychain.so survived ProGuard in ${foundIn.name}\n")
println("verifyJkeychainNativeSurvivesProguard: osxkeychain.so present in ${foundIn.name}")
}
}
// Gate every release-packaging task on the verification so a stripped .so
// never reaches a release artifact.
listOf(
"packageReleaseDmg",
"packageReleaseMsi",
"packageReleaseDeb",
"packageReleaseRpm",
"packageReleaseDistributionForCurrentOS",
"packageReleaseUberJarForCurrentOS",
"createReleaseDistributable",
"runReleaseDistributable",
).forEach { name ->
tasks.matching { it.name == name }.configureEach {
dependsOn(verifyJkeychainNativeSurvivesProguard)
}
}
// macOS notarization: the bundled jars (secp256k1, jna, sqlite-bundled, skiko,
// jkeychain, kdroidFilter mediaplayer) carry Mach-O natives that Compose's
// .app codesign never reaches — codesign doesn't descend into jars, but Apple's
// notary service does and rejects any unsigned Mach-O ("Invalid"). Sign those
// natives inside the *proguarded* jars (the exact artifacts Compose then copies
// into the .app) so the subsequent bundle signing seals already-signed code.
// Runs only on macOS with the Developer ID identity exported — a no-op on every
// other leg and on unsigned local/PR builds.
val signMacJarNatives = tasks.register("signMacJarNatives") {
description = "Codesign macOS Mach-O natives embedded in bundled jars before the .app is sealed + notarized"
group = "build"
dependsOn("proguardReleaseJars")
val proguardDir = layout.buildDirectory.dir("compose/tmp/main-release/proguard")
val script = rootProject.file("scripts/sign-macos-jar-natives.sh")
val identity = providers.environmentVariable("AMETHYST_MAC_SIGN_IDENTITY")
val isMac = System.getProperty("os.name").lowercase().contains("mac")
onlyIf { isMac && !identity.orNull.isNullOrBlank() }
doLast {
val proc =
ProcessBuilder("bash", script.absolutePath, proguardDir.get().asFile.absolutePath)
.redirectErrorStream(true)
.also { it.environment()["SIGN_IDENTITY"] = identity.get() }
.start()
proc.inputStream.bufferedReader().forEachLine { println(it) }
val code = proc.waitFor()
if (code != 0) throw GradleException("sign-macos-jar-natives.sh failed with exit code $code")
}
}
// Must run after ProGuard writes the jars and before Compose assembles + signs
// the .app, so wire it onto createReleaseDistributable (which the DMG packaging
// depends on transitively).
tasks.matching { it.name == "createReleaseDistributable" }.configureEach {
dependsOn(signMacJarNatives)
}