Files
amethyst/desktopApp/build.gradle.kts
T
Vitor PamplonaandClaude Opus 5 8020102364 perf: replace synchronous OkHttp execute() with executeAsync()
Every coroutine dispatched to Dispatchers.IO is stamped BlockingContext at
dispatch time, so its worker releases its CPU permit and the shared kotlinx
scheduler grows past ncpu. A blocking execute() holds one of those threads for
the whole request; executeAsync() suspends until the response headers arrive
and releases the thread across the network round-trip.

Converts 27 call sites across quartz, commons, amethyst, nestsClient,
desktopApp and cli. executeAsync() was already the house pattern (63 existing
uses); these were the stragglers.

The withContext(Dispatchers.IO) wrappers are kept on purpose: executeAsync()
only suspends until headers, and reading the body (string()/bytes()) is still
a blocking read. Moving those onto Dispatchers.Default would hold its
core-sized CPU permits and starve the pool.

Four private helpers become suspend (decodeGifFrames, fetchFromNetwork,
downloadFirstChunk, extractFirstFrame). Each had exactly one caller, already
inside a withContext(Dispatchers.IO) in a suspend function, so nothing
propagates further.

desktopApp gains the okhttp-coroutines dependency (Apache-2.0, same version as
the okhttp it already ships).

Not converted: NipCommand.fetchText and RelayCommands.info in the CLI. amy is a
short-lived process whose main is runBlocking { dispatch(argv) } — there is no
long-lived dispatcher pool or UI thread to protect there, so blocking is by
design and making them suspend would only add risk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:01:08 -04:00

422 lines
20 KiB
Kotlin

import org.jetbrains.compose.desktop.application.dsl.TargetFormat
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
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)
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)
// 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)
}
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)
// 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"))
}
}
}
// --- 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 by tasks.registering(Exec::class) {
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 by tasks.registering {
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 by tasks.registering {
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)
}