revenge-next-plugs/build.gradle.kts

305 lines
13 KiB
Text
Raw Normal View History

import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import groovy.json.JsonSlurper
import java.io.File
import java.util.concurrent.Callable
import javax.inject.Inject
import org.gradle.process.ExecOperations
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
}
val compileSdkVer = libs.versions.compileSdk.get()
val minSdkVer = libs.versions.minSdk.get()
val javaVer = libs.versions.javaVersion.get()
val revengeApiDep = libs.revenge.api.get().toString()
val xposedApiDep = libs.xposed.api.get().toString()
val coroutinesDep = libs.kotlinx.coroutines.android.get().toString()
val androidLibraryPluginId = libs.plugins.android.library.get().pluginId
// Gradle 9 removed Project.exec from task actions
interface ExecOpsProvider {
@get:Inject val execOps: ExecOperations
}
val rootExecOps = objects.newInstance<ExecOpsProvider>().execOps
// `./gradlew clean packageAllPlugins` also runs every `:plugins:<name>:clean`,
// which would delete freshly built artifacts mid-build; the tasks below `mustRunAfter` these.
val cleanTasks = Callable { rootProject.allprojects.mapNotNull { it.tasks.findByName("clean") } }
// ---------------------------------------------------------------------------------------------
// Plugins declared under plugins/<name>/manifest.json
// ---------------------------------------------------------------------------------------------
@Suppress("UNCHECKED_CAST")
fun parseJson(file: File) = JsonSlurper().parse(file) as Map<String, Any?>
@Suppress("UNCHECKED_CAST")
fun Map<String, Any?>.obj(key: String) = this[key] as? Map<String, Any?>
fun Map<String, Any?>.str(key: String) = this[key] as? String
data class PluginDef(val dir: File, val id: String, val jarName: String?, val scriptName: String?)
val pluginDefs = file("plugins").listFiles().orEmpty()
.filter(File::isDirectory)
.sortedBy(File::getName)
.mapNotNull { dir ->
val manifest = File(dir, "manifest.json").takeIf(File::isFile)?.let(::parseJson)
?: return@mapNotNull null
val id = manifest.str("id") ?: return@mapNotNull null
val dist = manifest.obj("dist").orEmpty()
PluginDef(dir, id, dist.obj("android")?.str("path"), dist.str("script"))
}
// ---------------------------------------------------------------------------------------------
// JS toolchain discovery
// ---------------------------------------------------------------------------------------------
// Gradle's exec runs with a minimal, non-login PATH that usually omits user-level install dirs,
// so the well-known locations are probed explicitly.
data class JsTool(val kind: String, val exe: File) {
val installCommand: List<String>
get() = when (kind) {
"bun" -> listOf(exe.absolutePath, "install", "--silent")
"npm" -> listOf(exe.absolutePath, "install", "--no-audit", "--no-fund", "--silent")
"deno" -> listOf(exe.absolutePath, "install", "--quiet")
else -> error("Unknown JS tool: $kind")
}
val buildCommand: List<String>
get() = when (kind) {
"bun", "npm" -> listOf(exe.absolutePath, "run", "build")
// Deno creates no node_modules/.bin shims, so `deno task build` cannot resolve the CLI,
// so we run its bin file directly.
"deno" -> listOf(
exe.absolutePath, "run", "-A",
"node_modules/@revenge-mod/plugin-cli/bin/revenge-plugin.js", "build",
)
else -> error("Unknown JS tool: $kind")
}
}
fun findJsTool(): JsTool? {
val kindOverride = findProperty("jsTool") as String?
val pathOverride = (findProperty("jsToolPath") as String?) ?: System.getenv("BUN")
if (!pathOverride.isNullOrBlank()) {
val exe = File(pathOverride).takeIf(File::isFile) ?: return null
return JsTool(kindOverride ?: exe.name.substringBefore('.'), exe)
}
val homes = buildSet {
System.getProperty("user.home")?.let(::add)
System.getenv("HOME")?.let(::add)
System.getProperty("user.name")?.let { user ->
add("/home/$user")
add("/var/home/$user") // atomic/ostree distros: /home -> /var/home
}
}
val pathDirs = System.getenv("PATH").orEmpty().split(File.pathSeparator).filter(String::isNotEmpty)
fun candidatesFor(kind: String): List<File> = buildList {
pathDirs.forEach { add(File(it, kind)) }
when (kind) {
"bun" -> homes.forEach { add(File(it, ".bun/bin/bun")) }
// nvm keeps npm next to node, under versions/node/<ver>/bin.
"npm" -> homes.forEach { home ->
File(home, ".nvm/versions/node").listFiles()
?.sortedDescending()
?.forEach { add(File(it, "bin/npm")) }
}
"deno" -> homes.forEach { add(File(it, ".deno/bin/deno")) }
}
add(File("/usr/local/bin/$kind"))
add(File("/opt/homebrew/bin/$kind"))
}
val kinds = kindOverride?.let(::listOf) ?: listOf("bun", "npm", "deno")
return kinds.firstNotNullOfOrNull { kind ->
candidatesFor(kind).firstOrNull(File::isFile)?.let { JsTool(kind, it) }
}
}
// ---------------------------------------------------------------------------------------------
// Native plugin modules (:plugins:<name>) are configured entirely from here,
// so a native plugin needs nothing but a src/main folder and a manifest.json.
// ---------------------------------------------------------------------------------------------
configure(subprojects.filter { it.path.startsWith(":plugins:") }) {
// AGP 9 has built-in Kotlin support, so org.jetbrains.kotlin.android isn't applied.
pluginManager.apply(androidLibraryPluginId)
extensions.configure<LibraryExtension> {
namespace = "revenge.plugin." + name.replace(Regex("[^A-Za-z0-9]"), "_")
compileSdk = compileSdkVer.toInt()
defaultConfig { minSdk = minSdkVer.toInt() }
compileOptions {
sourceCompatibility = JavaVersion.toVersion(javaVer)
targetCompatibility = JavaVersion.toVersion(javaVer)
}
sourceSets {
named("main") { kotlin.directories += "src/main/kotlin" }
}
}
extensions.configure<KotlinAndroidProjectExtension> {
jvmToolchain(javaVer.toInt())
}
// AGP's lint tasks require the module's build.gradle as an input, which these script-less modules don't have.
// There is nothing to lint here anyway.
tasks.configureEach {
if (name.contains("Lint") || name.startsWith("lint")) enabled = false
}
dependencies {
// Provided at runtime by the host, so never bundled
add("compileOnly", revengeApiDep)
add("compileOnly", xposedApiDep)
add("compileOnly", coroutinesDep)
}
val androidExt = extensions.getByType<LibraryExtension>()
val sdkDirProvider = extensions.getByType<LibraryAndroidComponentsExtension>().sdkComponents.sdkDirectory
val execOps = objects.newInstance<ExecOpsProvider>().execOps
tasks.register("dexJar") {
group = "revenge"
description = "Compiles this plugin into a DEX-containing JAR loadable by DexClassLoader."
dependsOn("compileReleaseKotlin", "compileReleaseJavaWithJavac")
mustRunAfter(cleanTasks)
// Only this module's own classes are DEXed. Dependencies compileOnly.
val classDirs = files(
// AGP 9 built-in Kotlin output, then the pre-AGP 9 location
layout.buildDirectory.dir("intermediates/built_in_kotlinc/release/compileReleaseKotlin/classes"),
layout.buildDirectory.dir("tmp/kotlin-classes/release"),
layout.buildDirectory.dir("intermediates/javac/release/classes"),
)
inputs.files(classDirs).withPropertyName("classDirs").optional()
val outJar = layout.buildDirectory.file("outputs/plugin/plugin.jar")
outputs.file(outJar).withPropertyName("dexJar")
val buildToolsVersion = androidExt.buildToolsVersion
doLast {
val classFiles = classDirs.asFileTree.matching { include("**/*.class") }.files
if (classFiles.isEmpty()) error("No compiled classes found to dex in $path.")
val sdkDir = sdkDirProvider.get().asFile
// Use newest d8 as the AGP-default build-tools could ship one too old for class files from a newer toolchain
val d8 = File(sdkDir, "build-tools").listFiles()
?.sortedByDescending(File::getName)
?.map { File(it, "d8") }
?.firstOrNull(File::isFile)
?: file("$sdkDir/build-tools/$buildToolsVersion/d8")
val androidJar = file("$sdkDir/platforms/android-$compileSdkVer/android.jar")
require(d8.exists()) { "d8 not found at $d8" }
require(androidJar.exists()) { "android.jar not found at $androidJar" }
val out = outJar.get().asFile
out.parentFile.mkdirs()
execOps.exec {
commandLine(
d8.absolutePath,
"--min-api", minSdkVer,
"--lib", androidJar.absolutePath,
"--output", out.absolutePath,
*classFiles.map { it.absolutePath }.toTypedArray(),
)
}
logger.lifecycle("Dexed $path -> ${out.relativeTo(rootProject.projectDir)}")
}
}
}
// ---------------------------------------------------------------------------------------------
// JS bundles (all plugins at once) and per-plugin packaging into build/dist/<id>.zip
// ---------------------------------------------------------------------------------------------
val buildJs = tasks.register("buildJs") {
group = "revenge"
description = "Builds every plugin's JS bundle via the revenge-plugin CLI."
mustRunAfter(cleanTasks)
// The bundler is @revenge-mod/plugin-cli, so the lockfiles (which pin its revision) are the inputs.
inputs.files(
"package.json", "tsconfig.json", "biome.json",
"bun.lock", "bun.lockb", "package-lock.json", "deno.lock",
).withPropertyName("config")
// Everything a plugin's bundle can be built from.
inputs.files(fileTree("plugins") { exclude("*/build/**", "*/src/**") }).withPropertyName("sources")
pluginDefs.filter { it.scriptName != null }.forEach {
outputs.dir(File(it.dir, "build/js")).withPropertyName("bundle-${it.dir.name}")
}
doLast {
val tool = findJsTool()
if (tool == null) {
logger.warn(
"No JS toolchain found (bun, npm, deno). Install Node >= 22.18, Bun or Deno 2, " +
"or pass -PjsTool=/-PjsToolPath=. Skipping JS build.",
)
return@doLast
}
logger.lifecycle("Building JS bundles with ${tool.kind} (${tool.exe})")
// Put the tool's own dir on PATH so nested calls (npm -> node, script -> bin shims) resolve.
val path = tool.exe.parentFile.absolutePath + File.pathSeparator + System.getenv("PATH").orEmpty()
listOf(tool.installCommand, tool.buildCommand).forEach { cmd ->
rootExecOps.exec {
workingDir = rootDir
environment("PATH", path)
commandLine(cmd)
}
}
}
}
val packageAllPlugins = tasks.register("packageAllPlugins") {
group = "revenge"
description = "Builds and packages every plugin into build/dist/<id>.zip."
}
fun taskSuffix(dirName: String): String =
dirName.split(Regex("[^A-Za-z0-9]")).filter(String::isNotEmpty)
.joinToString("") { it.replaceFirstChar(Char::uppercase) }
pluginDefs.forEach { (dir, id, jarName, scriptName) ->
val pkg = tasks.register<Zip>("package${taskSuffix(dir.name)}") {
group = "revenge"
description = "Packages '$id' into a distributable ZIP."
dependsOn(buildJs)
mustRunAfter(cleanTasks)
from(File(dir, "manifest.json"))
if (jarName != null && File(dir, "src/main").isDirectory) {
val nativePath = ":plugins:${dir.name}"
dependsOn("$nativePath:dexJar")
from(provider { project(nativePath).tasks.named("dexJar").get().outputs.files.singleFile }) {
rename { jarName }
}
}
if (scriptName != null) {
from(File(dir, "build/js")) {
include("index.js")
rename { scriptName }
}
}
archiveFileName.set("$id.zip")
destinationDirectory.set(layout.buildDirectory.dir("dist"))
doLast { logger.lifecycle("Packaged $id -> ${archiveFile.get().asFile.relativeTo(rootDir)}") }
}
packageAllPlugins.configure { dependsOn(pkg) }
}