mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Reverts the Kover coverage aggregation (introduced indca345212b) from every module, the root aggregation block, the Sonar coverage import, the version catalog, and BUILDING.md. With the plugin present the 16 GB CI runner OOM-killed during the Android test+build job even with Kover opt-in disabled; removing it entirely clears the OOM. No other CI changes — the diff versus main is exactly the inverse ofdca345212b. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
330 lines
13 KiB
Kotlin
330 lines
13 KiB
Kotlin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
|
|
plugins {
|
|
alias(libs.plugins.jetbrainsKotlinJvm)
|
|
application
|
|
}
|
|
|
|
kotlin {
|
|
jvmToolchain(21)
|
|
compilerOptions {
|
|
jvmTarget.set(JvmTarget.JVM_21)
|
|
}
|
|
}
|
|
|
|
sourceSets {
|
|
main {
|
|
kotlin.srcDir("src/main/kotlin")
|
|
resources.srcDir("src/main/resources")
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
implementation(project(":quartz"))
|
|
implementation(project(":commons"))
|
|
// `amy serve` embeds geode (the standalone Ktor relay built on quartz's
|
|
// relay-server code). geode depends only on :quartz, never on :amethyst.
|
|
implementation(project(":geode"))
|
|
|
|
implementation(libs.kotlinx.coroutines.core)
|
|
implementation(libs.kotlinx.serialization.json)
|
|
implementation(libs.okhttp)
|
|
implementation(libs.okhttpCoroutines)
|
|
implementation(libs.jackson.module.kotlin)
|
|
implementation(libs.slf4j.nop)
|
|
}
|
|
|
|
// amy is headless. It compiles against zero Compose UI (the Compose deps are
|
|
// `implementation` in :commons, so they never reach the CLI compile classpath —
|
|
// verified), yet they still ride the *runtime* classpath into the shipped image:
|
|
// ~29 MB of Compose desktop render stack, including skiko's native .dylibs that
|
|
// needlessly enlarge the macOS notarization surface. None of it is reachable
|
|
// from a CLI command, so drop the whole UI layer from the runtime image. Keep
|
|
// `androidx.compose.runtime` — snapshot state + the @Stable/@Immutable
|
|
// annotations ARE CLI-safe and used by commons models/state holders (see
|
|
// commons/ARCHITECTURE.md §1). A regression here is caught by the "no Compose UI
|
|
// in the amy image" assertion in .github/workflows/create-release.yml.
|
|
configurations.named("runtimeClasspath") {
|
|
// skiko (native renderer + its .dylibs) and the Compose UI bytecode layer.
|
|
// NB: Compose Multiplatform publishes UI under org.jetbrains.compose.* — only
|
|
// `runtime` relocates to androidx.compose.runtime, which we deliberately keep.
|
|
exclude(group = "org.jetbrains.skiko")
|
|
exclude(group = "org.jetbrains.compose.ui")
|
|
exclude(group = "org.jetbrains.compose.foundation")
|
|
exclude(group = "org.jetbrains.compose.material")
|
|
exclude(group = "org.jetbrains.compose.material3")
|
|
exclude(group = "org.jetbrains.compose.animation")
|
|
}
|
|
|
|
application {
|
|
mainClass.set("com.vitorpamplona.amethyst.cli.MainKt")
|
|
applicationName = "amy"
|
|
// amy is a non-interactive CLI — never spawn AWT GUI threads. Defensive
|
|
// against transitive deps that touch ImageIO / Toolkit during image
|
|
// upload (see commons/.../service/upload/ImageReencoder.kt). Belt-and-
|
|
// braces with the runtime System.setProperty in Main.kt.
|
|
applicationDefaultJvmArgs = listOf("-Djava.awt.headless=true")
|
|
}
|
|
|
|
// Inject `LANG=C.UTF-8` (and the matching Windows code page) into the
|
|
// launcher scripts when `LANG`/`LC_ALL` aren't already set. Without this,
|
|
// running amy under a POSIX/C locale leaves `sun.jnu.encoding` as
|
|
// `ANSI_X3.4-1968` (i.e. ASCII) — JEP 400 pins `file.encoding` to UTF-8
|
|
// but deliberately leaves `sun.jnu.encoding` tied to the OS locale, and
|
|
// it's read by the JVM *before* any `-D` flag has a chance to apply. So
|
|
// `-Dsun.jnu.encoding=UTF-8` does nothing; the encoding has to be set in
|
|
// the environment that launches Java.
|
|
//
|
|
// The symptom this fixes: `marmot message react "$gid" "$id" "🍕"` —
|
|
// the shell hands the four UTF-8 bytes (F0 9F 8D 95) to the JVM, the
|
|
// JVM decodes each one as ASCII (every byte > 0x7F → U+FFFD), and amy
|
|
// then signs a kind:7 whose `content` is four replacement characters.
|
|
// Whitenoise rejects it with "Invalid reaction content".
|
|
val patchAmyLauncherCharset by tasks.registering {
|
|
val appName = application.applicationName
|
|
val startScriptsTask = tasks.named("startScripts")
|
|
dependsOn(startScriptsTask)
|
|
// Patch the scripts at their source location so installDist (and any
|
|
// downstream packaging like jpackage) copies the patched copies.
|
|
doLast {
|
|
val unixScript = layout.buildDirectory.file("scripts/$appName").get().asFile
|
|
if (unixScript.exists()) {
|
|
val text = unixScript.readText()
|
|
val marker = "# Default LANG to UTF-8 so the JVM picks UTF-8 for sun.jnu.encoding"
|
|
if (!text.contains(marker)) {
|
|
// Build the snippet with explicit literal `$` characters to avoid
|
|
// regex-replacement-string escapes when we splice it in.
|
|
val injected = buildString {
|
|
append("\n")
|
|
append(marker).append("\n")
|
|
append("# (POSIX/C locales force ANSI_X3.4-1968, which mangles non-ASCII argv).\n")
|
|
append("if [ -z \"")
|
|
append("$").append("{LANG-}").append("$").append("{LC_ALL-}")
|
|
append("\" ]; then\n")
|
|
append(" export LANG=C.UTF-8\n")
|
|
append("fi\n")
|
|
}
|
|
// Insert right after the shebang. Use indexOf+substring instead
|
|
// of regex replaceFirst so the `$` chars in `injected` aren't
|
|
// mistaken for backreferences.
|
|
val nl = text.indexOf('\n')
|
|
val patched =
|
|
if (nl >= 0 && text.startsWith("#!")) {
|
|
text.substring(0, nl + 1) + injected + text.substring(nl + 1)
|
|
} else {
|
|
injected.trimStart('\n') + text
|
|
}
|
|
unixScript.writeText(patched)
|
|
}
|
|
}
|
|
val batScript = layout.buildDirectory.file("scripts/$appName.bat").get().asFile
|
|
if (batScript.exists()) {
|
|
val text = batScript.readText()
|
|
val marker = "rem Pin code page to UTF-8 so sun.jnu.encoding picks UTF-8"
|
|
if (!text.contains(marker)) {
|
|
val injected =
|
|
"$marker\r\n" +
|
|
"chcp 65001 > NUL 2>&1\r\n"
|
|
val anchor = "@if \"%DEBUG%\"==\"\" @echo off"
|
|
val idx = text.indexOf(anchor)
|
|
if (idx >= 0) {
|
|
val afterAnchor = text.indexOf('\n', idx)
|
|
if (afterAnchor >= 0) {
|
|
val patched =
|
|
text.substring(0, afterAnchor + 1) + injected + text.substring(afterAnchor + 1)
|
|
batScript.writeText(patched)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks.named("installDist") {
|
|
dependsOn(patchAmyLauncherCharset)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Native distribution (jlink + jpackage)
|
|
//
|
|
// Produces a self-contained `amy` bundle with a minimal jlink'd JRE embedded —
|
|
// no JDK required on the user machine. Outputs land under cli/build/:
|
|
// - amy-image/amy/ portable, flat directory (bin/ + lib/ + runtime/)
|
|
// tar this up on every OS → amy-<ver>-<fam>-<arch>.tar.gz
|
|
// - jpackage/amy_*.deb Debian/Ubuntu package (Linux runners only)
|
|
// - jpackage/amy-*.rpm Fedora/RHEL package (Linux runners only)
|
|
//
|
|
// We deliberately build our own app-image instead of using `jpackage --type
|
|
// app-image`, because on macOS jpackage produces an `.app` bundle (with the
|
|
// binary buried at Contents/MacOS/amy) — awful UX for a CLI. The flat tree we
|
|
// build matches the Linux jpackage layout on every OS.
|
|
//
|
|
// Packaging for release is wired in .github/workflows/create-release.yml.
|
|
// See cli/plans/2026-04-21-cli-distribution.md for the overall plan.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
val appVersion: String = project.version.toString()
|
|
|
|
// RPM rejects dashes in version strings — replace with tilde (~), which RPM
|
|
// treats as prerelease-lower-than: 1.08.0~rc1 < 1.08.0.
|
|
val rpmVersion: String = appVersion.replace("-", "~")
|
|
|
|
val mainJarName: String = "cli-$appVersion.jar"
|
|
val mainClassName: String = "com.vitorpamplona.amethyst.cli.MainKt"
|
|
|
|
// Minimal JDK 21 module set for amy. Keep this tight — every module adds
|
|
// megabytes to the bundle. If a transitive dep needs more, `jlink` fails loudly
|
|
// at build time with "module X not found".
|
|
val jlinkModules: String = listOf(
|
|
"java.base",
|
|
"java.logging",
|
|
"java.naming",
|
|
"java.net.http",
|
|
"java.sql",
|
|
"java.xml",
|
|
"jdk.crypto.ec",
|
|
"jdk.unsupported",
|
|
).joinToString(",")
|
|
|
|
fun javaToolBin(name: String): Provider<String> =
|
|
javaToolchains.launcherFor {
|
|
languageVersion.set(JavaLanguageVersion.of(21))
|
|
}.map {
|
|
val exe = if (org.gradle.internal.os.OperatingSystem.current().isWindows) "$name.exe" else name
|
|
it.metadata.installationPath.file("bin/$exe").asFile.absolutePath
|
|
}
|
|
|
|
val jlinkRuntimeDir = layout.buildDirectory.dir("jlink-runtime")
|
|
val amyImageRoot = layout.buildDirectory.dir("amy-image")
|
|
val amyImageDir = layout.buildDirectory.dir("amy-image/amy")
|
|
val installLibDir = layout.buildDirectory.dir("install/amy/lib")
|
|
val jpackageOutDir = layout.buildDirectory.dir("jpackage")
|
|
|
|
val jlinkRuntime =
|
|
tasks.register<Exec>("jlinkRuntime") {
|
|
group = "distribution"
|
|
description = "Build a minimal JRE for amy via jlink."
|
|
|
|
outputs.dir(jlinkRuntimeDir)
|
|
|
|
val jlinkBin = javaToolBin("jlink")
|
|
val outDir = jlinkRuntimeDir
|
|
val modules = jlinkModules
|
|
|
|
doFirst {
|
|
// jlink refuses to write into an existing directory.
|
|
outDir.get().asFile.deleteRecursively()
|
|
executable = jlinkBin.get()
|
|
args(
|
|
"--add-modules", modules,
|
|
"--no-header-files",
|
|
"--no-man-pages",
|
|
"--strip-debug",
|
|
// JDK 21+: --compress <int> is deprecated; use zip-<level>.
|
|
"--compress", "zip-6",
|
|
"--output", outDir.get().asFile.absolutePath,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Flat app-image: bin/amy launcher + lib/*.jar + runtime/ (the jlink'd JRE).
|
|
// Cross-platform — the release workflow tars this up on every OS.
|
|
val amyImage =
|
|
tasks.register<Sync>("amyImage") {
|
|
group = "distribution"
|
|
description = "Assemble a portable amy app-image (bin/ + lib/ + runtime/)."
|
|
|
|
dependsOn(tasks.named("installDist"), jlinkRuntime)
|
|
|
|
into(amyImageDir)
|
|
|
|
// jars from installDist
|
|
from(installLibDir) {
|
|
into("lib")
|
|
}
|
|
// jlink'd JRE
|
|
from(jlinkRuntimeDir) {
|
|
into("runtime")
|
|
}
|
|
|
|
val mainJar = mainJarName
|
|
val mainClass = mainClassName
|
|
val unixLauncher =
|
|
"""
|
|
#!/bin/sh
|
|
# amy launcher — uses the bundled jlink'd JRE so no system Java is required.
|
|
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
|
|
exec "${'$'}DIR/runtime/bin/java" -Djava.awt.headless=true -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
|
|
""".trimIndent() + "\n"
|
|
|
|
doLast {
|
|
val binDir = amyImageDir.get().asFile.resolve("bin")
|
|
binDir.mkdirs()
|
|
val launcher = binDir.resolve("amy")
|
|
launcher.writeText(unixLauncher)
|
|
launcher.setExecutable(true, false)
|
|
}
|
|
}
|
|
|
|
fun registerJpackage(
|
|
taskName: String,
|
|
type: String,
|
|
extraArgs: List<String> = emptyList(),
|
|
) = tasks.register<Exec>(taskName) {
|
|
group = "distribution"
|
|
description = "Run jpackage --type $type for amy."
|
|
|
|
dependsOn(tasks.named("installDist"), jlinkRuntime)
|
|
|
|
inputs.dir(installLibDir)
|
|
inputs.dir(jlinkRuntimeDir)
|
|
outputs.dir(jpackageOutDir)
|
|
|
|
val jpackageBin = javaToolBin("jpackage")
|
|
val inDir = installLibDir
|
|
val runtimeDir = jlinkRuntimeDir
|
|
val outDir = jpackageOutDir
|
|
val versionArg = if (type == "rpm") rpmVersion else appVersion
|
|
val extra = extraArgs
|
|
|
|
doFirst {
|
|
outDir.get().asFile.mkdirs()
|
|
executable = jpackageBin.get()
|
|
args(
|
|
"--type", type,
|
|
"--name", "amy",
|
|
"--app-version", versionArg,
|
|
"--vendor", "Amethyst Contributors",
|
|
"--description", "Amethyst CLI — a non-interactive Nostr client.",
|
|
"--input", inDir.get().asFile.absolutePath,
|
|
"--runtime-image", runtimeDir.get().asFile.absolutePath,
|
|
"--main-jar", mainJarName,
|
|
"--main-class", mainClassName,
|
|
"--dest", outDir.get().asFile.absolutePath,
|
|
)
|
|
args(extra)
|
|
}
|
|
}
|
|
|
|
// .deb for Debian/Ubuntu. Installs under /opt/amy/ with /opt/amy/bin/amy as
|
|
// the launcher. We intentionally do NOT request --linux-shortcut (no .desktop
|
|
// entry for a CLI).
|
|
registerJpackage(
|
|
"jpackageDeb",
|
|
"deb",
|
|
extraArgs = listOf(
|
|
"--linux-package-name", "amy",
|
|
"--linux-deb-maintainer", "vitor@vitorpamplona.com",
|
|
),
|
|
)
|
|
|
|
// .rpm for Fedora/RHEL/openSUSE.
|
|
registerJpackage(
|
|
"jpackageRpm",
|
|
"rpm",
|
|
extraArgs = listOf(
|
|
"--linux-package-name", "amy",
|
|
"--linux-rpm-license-type", "MIT",
|
|
),
|
|
)
|