Files
amethyst/desktopApp/build.gradle.kts
T
Claude c81dbb0127 feat(desktop): wire macOS Developer ID signing + notarization
Add gated code-signing + notarization for the macOS desktop DMG so it can
clear Gatekeeper and stay in Homebrew's main cask (unsigned casks are
rejected after 2026-09-01).

- desktopApp/build.gradle.kts: macOS signing{}/notarization{} blocks, gated
  on the AMETHYST_MAC_SIGN_IDENTITY env var. Absent => unsigned DMG, exactly
  as before, so local dev and PR CI are unaffected.
- create-release.yml: import a Developer ID cert into a throwaway keychain on
  the macOS leg and export the signing/notary env. Soft-gated on the
  MAC_CERTIFICATE_P12 secret — no secret => unsigned build.
- BUILDING.md: document the six MAC_* secrets, how to generate them, and flip
  the unsigned-cask fallback note to reflect the wiring is now in place
  (pending Apple credentials).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
2026-06-17 23:06:07 +00:00

284 lines
12 KiB
Kotlin

import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import java.nio.file.Files
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
implementation(project(":commons"))
// 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)
// 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)
// 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(compose.desktop.uiTestJUnit4)
}
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED"
jvmArgs += "-Xmx2g"
// 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`).
listOf("amethyst.platform", "amethyst.appearance", "amethyst.accent").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)
)
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)
}
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"
}
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"))
}
}
}
// --- 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-x86_64.AppImage.
// BUILDING.md documents local-dev fetch.
val createReleaseAppImage by tasks.registering(Exec::class) {
group = "compose desktop"
description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool."
dependsOn("createReleaseDistributable")
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-x86_64.AppImage")
val toolRoot = layout.projectDirectory.dir("packaging/appimage")
val appimagetool = toolRoot.file("appimagetool-x86_64.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", "x86_64")
// 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")
}