2
0

compositor.gradle.kts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. /*
  2. * Copyright (c) Milan Jurkulák 2026.
  3. * Contact:
  4. * e: mimoccc@gmail.com
  5. * e: mj@mjdev.org
  6. * w: https://mjdev.org
  7. */
  8. import org.jetbrains.kotlin.gradle.tasks.CInteropProcess
  9. plugins {
  10. // kotlin + serialization come from the root build classpath (declared apply-false there)
  11. kotlin("multiplatform")
  12. kotlin("plugin.serialization")
  13. }
  14. // identity from the shared version catalog (gradle/libs.versions.toml)
  15. val catalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
  16. group = catalog.findVersion("app-pkg-name").get().requiredVersion
  17. version = catalog.findVersion("app-pkg-version").get().requiredVersion
  18. val nativeDir = project.file("native")
  19. val protocolsDir = layout.buildDirectory.dir("generated/protocols")
  20. val shimDir = layout.buildDirectory.dir("shim")
  21. // pkg-config is queried lazily and never fails configuration,
  22. // so the JVM desktop build stays usable on machines without wlroots
  23. fun pkgConfig(vararg args: String): List<String> = runCatching {
  24. val process = ProcessBuilder(listOf("pkg-config") + args)
  25. .redirectErrorStream(false)
  26. .start()
  27. val out = process.inputStream.bufferedReader().readText()
  28. if (process.waitFor() != 0) emptyList()
  29. else out.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
  30. }.getOrElse { emptyList() }
  31. val wlrPackages = arrayOf("wlroots-0.18", "wayland-server", "xkbcommon", "pixman-1")
  32. val wlrCflags: List<String>
  33. get() = pkgConfig("--cflags", *wlrPackages)
  34. val wlrLibs: List<String>
  35. get() = pkgConfig("--libs", *wlrPackages).ifEmpty {
  36. listOf("-lwlroots-0.18", "-lwayland-server", "-lxkbcommon")
  37. }
  38. // the kotlin/native bundled linker does not search the system lib dirs
  39. val systemLibDirs = listOf(
  40. "/usr/lib/x86_64-linux-gnu",
  41. "/usr/lib",
  42. "/usr/local/lib",
  43. ).filter { file(it).isDirectory }.map { "-L$it" }
  44. val generateProtocols = tasks.register<Exec>("generateProtocols") {
  45. group = "mjdev"
  46. description = "Generates wayland protocol headers used by the compositor shim."
  47. val outDir = protocolsDir.get().asFile
  48. val xdgShellXml = "/usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml"
  49. inputs.files(xdgShellXml).optional()
  50. outputs.dir(outDir)
  51. commandLine(
  52. "bash", "-c",
  53. "mkdir -p '$outDir' && " +
  54. "wayland-scanner server-header '$xdgShellXml' '$outDir/xdg-shell-protocol.h'"
  55. )
  56. }
  57. val compileShim = tasks.register<Exec>("compileShim") {
  58. group = "mjdev"
  59. description = "Compiles the C shim over wlroots into a static library."
  60. dependsOn(generateProtocols)
  61. // capture everything as local values at configuration time so the doFirst closure
  62. // never reaches back into the build script (required for configuration-cache safety)
  63. val shimC = nativeDir.resolve("shim.c")
  64. val shimH = nativeDir.resolve("shim.h")
  65. val outDir = shimDir.get().asFile
  66. val protoDir = protocolsDir.get().asFile
  67. val includeDir = nativeDir
  68. val cflags = wlrCflags.joinToString(" ")
  69. inputs.files(shimC, shimH)
  70. outputs.file(outDir.resolve("libmjcshim.a"))
  71. doFirst {
  72. commandLine(
  73. "bash", "-c",
  74. "mkdir -p '$outDir' && " +
  75. "cc -O2 -fPIC -DWLR_USE_UNSTABLE -std=c11 " +
  76. "-I'$protoDir' -I'$includeDir' $cflags " +
  77. "-c '$shimC' -o '$outDir/shim.o' && " +
  78. "ar rcs '$outDir/libmjcshim.a' '$outDir/shim.o'"
  79. )
  80. }
  81. // placeholder, replaced in doFirst (Exec requires a commandLine at configuration)
  82. commandLine("true")
  83. }
  84. kotlin {
  85. linuxX64 {
  86. compilations.getByName("main") {
  87. cinterops.create("shim") {
  88. defFile(nativeDir.resolve("shim.def"))
  89. packageName("mjdev.compositor.shim")
  90. includeDirs(nativeDir)
  91. }
  92. }
  93. binaries {
  94. executable("mjdevc") {
  95. entryPoint = "eu.mjdev.compositor.main"
  96. // link against the system glibc instead of the old bundled
  97. // sysroot, system libs (wlroots & co) need modern symbols
  98. val gccVersion = file("/usr/lib/gcc/x86_64-linux-gnu")
  99. .listFiles()?.map { it.name }?.maxByOrNull { it.toIntOrNull() ?: 0 }
  100. if (gccVersion != null) {
  101. freeCompilerArgs += listOf(
  102. "-Xoverride-konan-properties=" +
  103. "targetSysRoot.linux_x64=/;" +
  104. "libGcc.linux_x64=/usr/lib/gcc/x86_64-linux-gnu/$gccVersion;" +
  105. "crtFilesLocation.linux_x64=usr/lib/x86_64-linux-gnu"
  106. )
  107. }
  108. linkerOpts(systemLibDirs)
  109. linkerOpts(shimDir.get().asFile.resolve("libmjcshim.a").absolutePath)
  110. linkerOpts(wlrLibs)
  111. }
  112. }
  113. }
  114. sourceSets {
  115. getByName("linuxX64Main") {
  116. dependencies {
  117. implementation(catalog.findLibrary("kotlinx-serialization-json").get())
  118. }
  119. }
  120. }
  121. }
  122. // the static shim library must exist before linking the executable; it is passed via
  123. // linkerOpts (an absolute path), which Kotlin/Native does not track — so register it as an
  124. // explicit input, otherwise editing shim.c rebuilds the .a but never relinks the binary
  125. // (you would keep running a stale executable).
  126. tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink>().configureEach {
  127. dependsOn(compileShim)
  128. inputs.file(shimDir.map { it.file("libmjcshim.a") })
  129. .withPropertyName("mjcShimLibrary")
  130. }
  131. // note: cinterop parses only the self contained shim.h,
  132. // generated wayland protocol headers are needed just for compileShim
  133. tasks.register<Copy>("stageSession") {
  134. group = "mjdev"
  135. description = "Stages the compositor binary and session files for installation."
  136. dependsOn("linkMjdevcReleaseExecutableLinuxX64")
  137. from(layout.buildDirectory.file("bin/linuxX64/mjdevcReleaseExecutable/mjdevc.kexe")) {
  138. rename { "mjdevc" }
  139. }
  140. from(rootProject.file("session/mjdev-session"))
  141. from(rootProject.file("session/mjdev.desktop"))
  142. into(layout.buildDirectory.dir("session-install"))
  143. }
  144. // Privileged install steps for the compositor + wayland session. Installs the binary, the
  145. // session launcher and the wayland-sessions entry, and removes the stale 2024 X11
  146. // "mjdev-desktop" session that shadows ours (same display name) and breaks login. Lines are
  147. // built at configuration time (staged paths are static) so the Exec stays config-cache safe.
  148. // built at configuration time (staged paths are static) so referencing the result from doLast
  149. // stays configuration-cache safe (the function itself is never captured in the task action).
  150. fun sessionInstallLines(staged: File): List<String> = listOf(
  151. "#!/bin/sh",
  152. "set -e",
  153. "install -Dm755 '${staged.resolve("mjdevc")}' /usr/local/bin/mjdevc",
  154. "install -Dm755 '${staged.resolve("mjdev-session")}' /usr/local/bin/mjdev-session",
  155. "install -Dm644 '${staged.resolve("mjdev.desktop")}' /usr/share/wayland-sessions/mjdev.desktop",
  156. "rm -f /usr/share/xsessions/mjdev-desktop.desktop " +
  157. "/usr/share/gnome-session/sessions/mjdev-desktop.session " +
  158. "/usr/share/applications/mjdev-desktop-mjdev-desktop.desktop",
  159. )
  160. // sudo can't read a password from the Gradle daemon (no TTY), so these tasks don't exec it —
  161. // they stage everything, write a self-contained install script under build/, and print the one
  162. // sudo command for the user to run in their own terminal.
  163. tasks.register("installSession") {
  164. group = "mjdev"
  165. description = "Stages the compositor + wayland session, prints the sudo install command."
  166. dependsOn("stageSession")
  167. val staged = layout.buildDirectory.dir("session-install").get().asFile
  168. val lines = sessionInstallLines(staged)
  169. doLast {
  170. val script = staged.resolve("install.sh")
  171. script.writeText(lines.joinToString("\n") + "\n")
  172. println("Session staged. Install it (single sudo prompt) with:")
  173. println(" sudo sh '${script.absolutePath}'")
  174. }
  175. }
  176. // Complete (re)install: compositor + wayland session + the desktop app .deb (the session's
  177. // shell, /opt/mjdev-desktop), and the stale-session cleanup — one script run as root via a
  178. // pkexec (polkit) authentication dialog. pkexec needs no TTY (unlike sudo), so it works from
  179. // the detached Gradle daemon and prompts graphically; if it's missing we print the sudo command.
  180. tasks.register<Exec>("installDesktop") {
  181. group = "mjdev"
  182. description = "Builds compositor + session + app .deb and installs it via a pkexec authentication dialog."
  183. dependsOn("stageSession", ":desktopApp:packageReleaseDeb")
  184. val staged = layout.buildDirectory.dir("session-install").get().asFile
  185. val lines = sessionInstallLines(staged)
  186. // desktopApp overrides compose outputBaseDir to <root>/packages, so the .deb is written
  187. // to packages/main-release/deb (not desktopApp/build/compose/binaries/…)
  188. val debDir = rootProject.rootDir.resolve("packages/main-release/deb")
  189. isIgnoreExitValue = false
  190. doFirst {
  191. val deb = debDir.listFiles { f -> f.extension == "deb" }?.firstOrNull()
  192. ?: error("desktop .deb not found in $debDir — run :desktopApp:packageReleaseDeb")
  193. val script = staged.resolve("install-desktop.sh")
  194. script.writeText((lines + "dpkg -i '${deb.absolutePath}'").joinToString("\n") + "\n")
  195. // pkexec pops a graphical polkit auth dialog and runs the script as root; needs a polkit
  196. // agent in the session but no terminal. Fall back to a printed sudo command if absent.
  197. val pkexec = listOf("/usr/bin/pkexec", "/usr/local/bin/pkexec")
  198. .firstOrNull { File(it).canExecute() }
  199. if (pkexec != null) {
  200. println("Installing the mjdev desktop — approve the authentication dialog…")
  201. commandLine(pkexec, "sh", script.absolutePath)
  202. } else {
  203. println("pkexec not found. Install the desktop (single sudo prompt) with:")
  204. println(" sudo sh '${script.absolutePath}'")
  205. commandLine("true")
  206. }
  207. }
  208. commandLine("true")
  209. }
  210. tasks.register<Exec>("runNested") {
  211. group = "mjdev"
  212. description = "Runs the compositor nested inside the current Wayland session."
  213. dependsOn("linkMjdevcDebugExecutableLinuxX64")
  214. val shellCmd = (project.findProperty("shellCmd") as String?)
  215. val binary = layout.buildDirectory
  216. .file("bin/linuxX64/mjdevcDebugExecutable/mjdevc.kexe")
  217. .get().asFile.absolutePath
  218. // x11 backend -> the host window manager decorates the nested window (frame + close +
  219. // move); 16:9 nested output (the compositor locks it against resize)
  220. environment("WLR_BACKENDS", "x11")
  221. environment("MJDEVC_OUTPUT", "1280x720")
  222. doFirst {
  223. // host X11 auth for the nested wlroots x11 backend, resolved at execution time:
  224. // the detached Gradle daemon often lacks a valid XAUTHORITY, and mutter writes a
  225. // per-session, randomly-suffixed auth file under XDG_RUNTIME_DIR
  226. environment("DISPLAY", System.getenv("DISPLAY") ?: ":0")
  227. val xauthCandidates = buildList {
  228. System.getenv("XAUTHORITY")?.let { add(File(it)) }
  229. System.getenv("XDG_RUNTIME_DIR")?.let { dir ->
  230. File(dir).listFiles()?.forEach { f ->
  231. if (f.name.startsWith(".mutter-Xwaylandauth.")) add(f)
  232. }
  233. }
  234. add(File(System.getProperty("user.home"), ".Xauthority"))
  235. }
  236. xauthCandidates.firstOrNull { it.isFile }?.let { environment("XAUTHORITY", it.absolutePath) }
  237. val args = mutableListOf(binary)
  238. if (shellCmd != null) {
  239. args += listOf("--shell-cmd", shellCmd)
  240. }
  241. commandLine(args)
  242. }
  243. commandLine("true")
  244. }
  245. // Runs the Compose desktop app as the shell client inside the nested compositor.
  246. // The desktop app is packaged with its own JRE (createDistributable) and launched via
  247. // XWayland, so it shows up as a window managed by mjdevc (fixed 16:9, movable, closable).
  248. tasks.register<Exec>("runNestedDesktop") {
  249. group = "mjdev"
  250. description = "Runs the mjdev desktop app as a client inside the nested compositor."
  251. dependsOn("linkMjdevcDebugExecutableLinuxX64", ":desktopApp:createDistributable")
  252. val binary = layout.buildDirectory
  253. .file("bin/linuxX64/mjdevcDebugExecutable/mjdevc.kexe")
  254. .get().asFile.absolutePath
  255. // desktopApp overrides outputBaseDir to <root>/packages, so createDistributable
  256. // writes the app image to packages/main/app/<name>/bin/<name> (not build/compose/…)
  257. val appDir = rootProject.rootDir.resolve("packages/main/app")
  258. // x11 backend -> host-decorated nested window (frame + close + move); fixed 16:9 output
  259. environment("WLR_BACKENDS", "x11")
  260. environment("MJDEVC_OUTPUT", "1280x720")
  261. doFirst {
  262. // host X11 auth for the nested wlroots x11 backend, resolved at execution time:
  263. // the detached Gradle daemon often lacks a valid XAUTHORITY, and mutter writes a
  264. // per-session, randomly-suffixed auth file under XDG_RUNTIME_DIR
  265. environment("DISPLAY", System.getenv("DISPLAY") ?: ":0")
  266. val xauthCandidates = buildList {
  267. System.getenv("XAUTHORITY")?.let { add(File(it)) }
  268. System.getenv("XDG_RUNTIME_DIR")?.let { dir ->
  269. File(dir).listFiles()?.forEach { f ->
  270. if (f.name.startsWith(".mutter-Xwaylandauth.")) add(f)
  271. }
  272. }
  273. add(File(System.getProperty("user.home"), ".Xauthority"))
  274. }
  275. xauthCandidates.firstOrNull { it.isFile }?.let { environment("XAUTHORITY", it.absolutePath) }
  276. val launcher = appDir.walkTopDown()
  277. .firstOrNull { it.isFile && it.parentFile?.name == "bin" && it.canExecute() }
  278. ?: error("desktop launcher not found under $appDir — run :desktopApp:createDistributable")
  279. commandLine(binary, "--shell-cmd", launcher.absolutePath)
  280. }
  281. commandLine("true")
  282. }