build.gradle.kts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
  2. import org.jlleitschuh.gradle.ktlint.KtlintExtension
  3. import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
  4. plugins {
  5. // alias(libs.plugins.devtools.ksp) apply false
  6. alias(libs.plugins.android.application) apply false
  7. alias(libs.plugins.android.library) apply false
  8. alias(libs.plugins.compose) apply false
  9. alias(libs.plugins.compose.compiler) apply false
  10. alias(libs.plugins.kotlin.jvm) apply false
  11. alias(libs.plugins.kotlin.multiplatform) apply false
  12. alias(libs.plugins.kotlin.serialization) apply false
  13. alias(libs.plugins.app.icon.generator) apply false
  14. alias(libs.plugins.qodana)
  15. // alias(libs.plugins.kover)
  16. alias(libs.plugins.changelog) apply false
  17. alias(libs.plugins.gradle.ktlint)
  18. alias(libs.plugins.manes.versions)
  19. alias(libs.plugins.kotlin.android) apply false
  20. id("AiAgentPlugin")
  21. // id("com.github.gmazzo.buildconfig") version "5.6.5"
  22. }
  23. // ============================================================================
  24. // Code quality + dependency reporting -> /reports
  25. // - ben-manes: stable-only dependency-update report (rejects alpha/rc/dev/tethys/snapshot)
  26. // - ktlint: auto-format after build + report; report-only (never breaks the build)
  27. // - After every build: auto-format code, ktlint report, and (gated) dependency report.
  28. // Dependency check is ON by default; disable with -PdepCheck=false
  29. // ============================================================================
  30. // A version is "non-stable" if it is a milestone/preview build (alpha, beta, rc,
  31. // eap, dev, snapshot, tethys, …) — we never auto-suggest those over a stable current.
  32. fun isNonStable(version: String): Boolean {
  33. val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) }
  34. val regex = "^[0-9,.v-]+(-r)?$".toRegex()
  35. return !(stableKeyword || regex.matches(version))
  36. }
  37. val depCheckEnabled: Boolean = (findProperty("depCheck") as String?)?.toBooleanStrictOrNull() ?: true
  38. val reportsDir: Directory = rootProject.layout.projectDirectory.dir("reports")
  39. allprojects {
  40. apply(plugin = "com.github.ben-manes.versions")
  41. apply(plugin = "org.jlleitschuh.gradle.ktlint")
  42. extensions.configure<KtlintExtension> {
  43. ignoreFailures.set(true) // report only — never fail the build
  44. android.set(true)
  45. filter {
  46. // never lint/format generated or build output (compose resources, KSP, etc.)
  47. exclude { it.file.path.contains("${File.separator}build${File.separator}") }
  48. exclude { it.file.path.contains("generated") }
  49. }
  50. reporters {
  51. reporter(ReporterType.PLAIN)
  52. reporter(ReporterType.HTML)
  53. reporter(ReporterType.CHECKSTYLE)
  54. }
  55. }
  56. tasks.withType<DependencyUpdatesTask>().configureEach {
  57. rejectVersionIf { isNonStable(candidate.version) && !isNonStable(currentVersion) }
  58. outputDir = reportsDir.dir("dependencies").asFile.absolutePath
  59. reportfileName = "dependency-updates"
  60. outputFormatter = "plain,html"
  61. }
  62. }
  63. // Collect ktlint reports from every module into /reports/ktlint/<module>
  64. val collectKtlintReports by tasks.registering(Copy::class) {
  65. group = "reporting"
  66. description = "Collects ktlint reports from all modules into /reports/ktlint"
  67. allprojects.forEach { p ->
  68. from(p.layout.buildDirectory.dir("reports/ktlint")) { into(p.name) }
  69. }
  70. into(reportsDir.dir("ktlint"))
  71. duplicatesStrategy = DuplicatesStrategy.INCLUDE
  72. // ensure reports are produced before we collect them
  73. mustRunAfter(allprojects.flatMap { p -> p.tasks.matching { it.name == "ktlintCheck" || it.name == "ktlintFormat" } })
  74. }
  75. // Runs after every build: auto-format, ktlint report, and (gated) dependency-update report -> /reports
  76. val postBuildCodeCheck by tasks.registering {
  77. group = "verification"
  78. description = "Auto-formats code (ktlintFormat) and runs the gated dependency-update report into /reports"
  79. // Auto-format on build. Safe because .editorconfig disables no-unused-imports — the one
  80. // rule that stripped receiver/extension-member imports and broke compilation.
  81. dependsOn(":shared:ktlintFormat")
  82. if (depCheckEnabled) dependsOn(":shared:dependencyUpdates")
  83. finalizedBy(collectKtlintReports)
  84. }
  85. allprojects {
  86. tasks.matching { it.name == "build" }.configureEach {
  87. finalizedBy(rootProject.tasks.named("postBuildCodeCheck"))
  88. }
  89. }
  90. //buildConfig {
  91. // generateAtSync = true
  92. // sourceSets {
  93. // main {
  94. // packageName = "org.mjdev.desktop"
  95. // useKotlin = true
  96. // useJava = true
  97. // useGroovy = true
  98. // useScala = true
  99. // useXml = true
  100. // useProperties = true
  101. // }
  102. // }
  103. // buildConfigField("APP_NAME", project.name)
  104. // buildConfigField("APP_VERSION", provider { "\"${project.version}\"" })
  105. // buildConfigField("APP_SECRET", "Z3JhZGxlLWphdmEtYnVpbGRjb25maWctcGx1Z2lu")
  106. // buildConfigField<String>("OPTIONAL", null)
  107. // buildConfigField("BUILD_TIME", System.currentTimeMillis())
  108. // buildConfigField("FEATURE_ENABLED", true)
  109. // buildConfigField("MAGIC_NUMBERS", intArrayOf(1, 2, 3, 4))
  110. // buildConfigField("STRING_LIST", arrayOf("a", "b", "c"))
  111. // buildConfigField("MAP", mapOf("a" to 1, "b" to 2))
  112. // buildConfigField("FILE", File("aFile"))
  113. // buildConfigField("URI", uri("https://example.io"))
  114. // buildConfigField("com.github.gmazzo.buildconfig.demos.kts.SomeData", "DATA", "SomeData(\"a\", 1)")
  115. //}
  116. //dependencies {
  117. // kover(project(":composeApp"))
  118. //}
  119. //kover { reports { total { xml { onCheck = true } } } }
  120. // Aggregate task: build every distributable that this host can produce.
  121. // - desktop: Deb + AppImage + Rpm (per the targetFormats in composeApp), release mode
  122. // - android: release APK (assembleRelease)
  123. // - ios: only when an iOS Kotlin target is configured AND the host is macOS
  124. // (Kotlin/Native for iOS cannot build on Linux/Windows) — wired below if present.
  125. // captured at configuration time -> all the tasks below stay configuration-cache safe
  126. val appNameV = libs.versions.app.name.get()
  127. val versionV = libs.versions.app.pkg.version.get()
  128. val pkgDirV = rootDir.resolve("packages/main-release")
  129. val appImageToolPath = rootDir.resolve(".gradle/tools/appimagetool-x86_64.AppImage").absolutePath
  130. // Downloads appimagetool before the build (only if not already present / on PATH).
  131. val ensureAppImageTool = tasks.register<EnsureAppImageToolTask>("ensureAppImageTool") {
  132. group = "mjdev"
  133. description = "Downloads appimagetool (single-file .AppImage builder) if not already present."
  134. toolPath.set(appImageToolPath)
  135. }
  136. // Wraps the jpackage app-image directory into a single, CLI-runnable .AppImage file.
  137. val packageAppImageFile = tasks.register<PackageAppImageTask>("packageAppImageFile") {
  138. group = "mjdev"
  139. description = "Builds a single-file .AppImage from the jpackage app-image (skips if appimagetool unavailable)."
  140. dependsOn(ensureAppImageTool, ":desktopApp:packageReleaseAppImage")
  141. appName.set(appNameV)
  142. appImagePath.set(pkgDirV.resolve("app/$appNameV").absolutePath)
  143. outputPath.set(pkgDirV.resolve("appimage/$appNameV.AppImage").absolutePath)
  144. toolPath.set(appImageToolPath)
  145. }
  146. // Turns the jpackage desktop .deb into a *complete, self-installable* package: injects the
  147. // compositor + session + wayland-sessions entry and declares the wayland runtime stack in
  148. // Depends, so `apt install ./mjdev-desktop.deb` on a clean console Linux pulls libwlroots/
  149. // xwayland/mesa/... and the desktop actually starts. Overwrites the deb in place so every
  150. // downstream consumer (collectReleases, makeIso, installDesktop) uses the full deb.
  151. // runtime wayland stack from the version catalog (single source of truth — not hardcoded here).
  152. val compositorRuntimeDeps = libs.versions.app.compositor.runtime.deps.get().trim().split(Regex("\\s+"))
  153. val packageFullDeb = tasks.register<PackageFullDebTask>("packageFullDeb") {
  154. group = "mjdev"
  155. description = "Repacks the desktop .deb with the compositor + session + wayland runtime Depends (self-installable)."
  156. dependsOn(":desktopApp:packageReleaseDeb", ":compositor:stageSession")
  157. debDir.set(pkgDirV.resolve("deb").absolutePath)
  158. sessionDir.set(rootDir.resolve("compositor/build/session-install").absolutePath)
  159. runtimeDepends.set(compositorRuntimeDeps)
  160. }
  161. // Copies all distributables into releases/ with version in the filename
  162. // (mjdev-desktop-<version>.<ext>). Copy = configuration-cache safe.
  163. val collectReleases = tasks.register<Copy>("collectReleases") {
  164. group = "mjdev"
  165. description = "Copies the built distributables into releases/ (versioned filenames)."
  166. dependsOn(
  167. ":desktopApp:packageReleaseDistributionForCurrentOS",
  168. ":androidApp:assembleRelease",
  169. packageAppImageFile,
  170. packageFullDeb,
  171. )
  172. // local copies so the rename closures capture plain Strings (configuration-cache safe)
  173. val base = "$appNameV-$versionV"
  174. into(layout.projectDirectory.dir("releases"))
  175. from(pkgDirV.resolve("deb")) { include("*.deb"); rename { "$base.deb" } }
  176. from(pkgDirV.resolve("rpm")) { include("*.rpm"); rename { "$base.rpm" } }
  177. from(pkgDirV.resolve("exe")) { include("*.exe"); rename { "$base.exe" } } // present only on a Windows host
  178. from(pkgDirV.resolve("appimage")) { include("*.AppImage"); rename { "$base.AppImage" } }
  179. from(rootDir.resolve("androidApp/build/outputs/apk/release")) {
  180. include("*.apk"); rename { "$base.apk" }
  181. }
  182. }
  183. // Builds the bootable live ISO (releases/<app>-<ver>.iso) from the freshly built
  184. // desktop deb + compositor/session + the theme debs in deb-packages/, via make-iso.sh.
  185. // debootstrap/chroot/mksquashfs need root: when the build is already root (CI) we run
  186. // the script directly; otherwise pkexec shows a graphical password dialog (sudo cannot
  187. // prompt without a TTY). If the iso toolchain isn't installed the task logs a skip and
  188. // returns 0, so it never breaks buildAll on a plain dev machine.
  189. val makeIso = tasks.register("makeIso") {
  190. group = "mjdev"
  191. description = "Builds the minimal bootable mjdev desktop live ISO into releases/ (needs root / pkexec)."
  192. dependsOn(":desktopApp:packageReleaseDeb", ":compositor:stageSession", packageFullDeb)
  193. // capture plain Strings at configuration time — the doLast closure must not reference
  194. // script-level vals (that captures the script instance, which is null under the
  195. // configuration cache -> "Cannot invoke getDebDirV() because this$0 is null").
  196. val isoOutPath = rootDir.resolve("releases/$appNameV-$versionV.iso").absolutePath
  197. val makeIsoScriptPath = rootDir.resolve("make-iso.sh").absolutePath
  198. val debDirPath = pkgDirV.resolve("deb").absolutePath
  199. val sessionStagePath = rootDir.resolve("compositor/build/session-install").absolutePath
  200. val extraDebsPath = rootDir.resolve("deb-packages").absolutePath
  201. doLast {
  202. val isoTools = listOf("debootstrap", "mksquashfs", "xorriso", "grub-mkrescue")
  203. // debootstrap/grub-mkrescue live in /usr/sbin — which is often absent from the Gradle
  204. // daemon's PATH (notably on CI), so search the sbin dirs too or makeIso wrongly skips.
  205. val toolDirs = (System.getenv("PATH").orEmpty().split(File.pathSeparator) +
  206. listOf("/usr/sbin", "/sbin", "/usr/local/sbin")).filter { it.isNotEmpty() }
  207. val missing = isoTools.filter { tool -> toolDirs.none { dir -> File(dir, tool).canExecute() } }
  208. if (missing.isNotEmpty()) {
  209. logger.warn("::warning::makeIso: skipping ISO — missing tools: ${missing.joinToString(" ")} " +
  210. "(apt install debootstrap squashfs-tools xorriso grub-common grub-pc-bin grub-efi-amd64-bin)")
  211. return@doLast
  212. }
  213. val deb = File(debDirPath).listFiles { f -> f.extension == "deb" }?.firstOrNull()
  214. ?: error("desktop .deb not found in $debDirPath — run :desktopApp:packageReleaseDeb")
  215. val args = mutableListOf<String>()
  216. val isRoot = (System.getenv("USER") == "root") ||
  217. runCatching { ProcessBuilder("id", "-u").start().inputStream.bufferedReader().readText().trim() == "0" }.getOrDefault(false)
  218. when {
  219. isRoot -> {}
  220. File("/usr/bin/pkexec").canExecute() && !System.getenv("DISPLAY").isNullOrBlank() -> args += "pkexec"
  221. else -> args += "sudo"
  222. }
  223. args += listOf("/bin/bash", makeIsoScriptPath,
  224. "--deb", deb.absolutePath,
  225. "--compositor-bin", File(sessionStagePath, "mjdevc").absolutePath,
  226. "--session-dir", sessionStagePath,
  227. "--extra-debs", extraDebsPath,
  228. "--out", isoOutPath)
  229. logger.lifecycle("makeIso: ${args.joinToString(" ")}")
  230. val code = ProcessBuilder(args).inheritIO().start().waitFor()
  231. if (code != 0) {
  232. logger.warn(
  233. "::warning::makeIso: make-iso.sh failed (exit $code) — ISO skipped; " +
  234. "other distributables are still published",
  235. )
  236. }
  237. }
  238. }
  239. // Boots the built ISO in QEMU (no root needed).
  240. tasks.register("runIsoQemu") {
  241. group = "mjdev"
  242. description = "Boots the mjdev desktop ISO in QEMU."
  243. val script = rootDir.resolve("run-iso-qemu.sh")
  244. doLast {
  245. val code = ProcessBuilder("/bin/bash", script.absolutePath).inheritIO().start().waitFor()
  246. check(code == 0) { "run-iso-qemu.sh failed (exit $code)" }
  247. }
  248. }
  249. // ISO is built after collectReleases so deb/rpm/AppImage/apk land in releases/ even when
  250. // make-iso.sh is slow or fails; makeIso never fails the aggregate (warn-only on error).
  251. makeIso.configure { mustRunAfter(collectReleases) }
  252. val buildAll = tasks.register("buildAll") {
  253. group = "mjdev"
  254. description = "Builds all distributables this host can produce, collects them into releases/ (stable names), and generates reports into reports/ — like every build."
  255. dependsOn(collectReleases, makeIso, postBuildCodeCheck)
  256. }
  257. // Attach iOS framework build only when an iOS target actually exists in composeApp
  258. // (it never does on a Linux/Windows host — Kotlin/Native iOS requires macOS + Xcode).
  259. project(":shared").afterEvaluate {
  260. val iosTask = tasks.names.firstOrNull {
  261. it.startsWith("linkReleaseFrameworkIos") || it == "embedAndSignAppleFrameworkForXcode"
  262. }
  263. if (iosTask != null) {
  264. buildAll.configure { dependsOn("${this@afterEvaluate.path}:$iosTask") }
  265. logger.lifecycle("buildAll: iOS target found — wiring ${this@afterEvaluate.path}:$iosTask")
  266. } else {
  267. logger.info("buildAll: no iOS target configured (needs macOS + Xcode + an ios* target in composeApp) — iOS skipped")
  268. }
  269. }