From b64505d47662419e9332a41cdf7a883b60fb2fba Mon Sep 17 00:00:00 2001 From: Stef Tervelde Date: Mon, 7 Jul 2025 16:21:59 +0200 Subject: [PATCH] Gradle Service refactor --- app/src/processing/app/gradle/Debugger.kt | 1 - app/src/processing/app/gradle/GradleJob.kt | 306 +++++++++++++----- .../processing/app/gradle/GradleService.kt | 153 +-------- app/src/processing/app/gradle/Log.kt | 30 ++ 4 files changed, 259 insertions(+), 231 deletions(-) create mode 100644 app/src/processing/app/gradle/Log.kt diff --git a/app/src/processing/app/gradle/Debugger.kt b/app/src/processing/app/gradle/Debugger.kt index bdc78c954..aea18ec13 100644 --- a/app/src/processing/app/gradle/Debugger.kt +++ b/app/src/processing/app/gradle/Debugger.kt @@ -10,7 +10,6 @@ import kotlin.time.TimeSource class Debugger { companion object { - // TODO: Stop if build has failed suspend fun connect(port: Int?): VirtualMachine? { try { Messages.log("Attaching to VM $port") diff --git a/app/src/processing/app/gradle/GradleJob.kt b/app/src/processing/app/gradle/GradleJob.kt index fd90884ae..2904d4164 100644 --- a/app/src/processing/app/gradle/GradleJob.kt +++ b/app/src/processing/app/gradle/GradleJob.kt @@ -17,14 +17,29 @@ import org.gradle.tooling.events.problems.internal.DefaultSingleProblemEvent import org.gradle.tooling.events.task.TaskFinishEvent import org.gradle.tooling.events.task.TaskStartEvent import org.gradle.tooling.events.task.TaskSuccessResult -import processing.app.Base +import processing.app.Base.DEBUG +import processing.app.Base.getSketchbookFolder +import processing.app.Base.getVersionName +import processing.app.Language.text import processing.app.Messages import processing.app.Platform +import processing.app.Platform.getContentFile +import processing.app.Platform.getSettingsFolder +import processing.app.Sketch +import processing.app.gradle.Log.Companion.startLogServer +import processing.app.ui.Editor import processing.app.ui.EditorStatus -import java.io.PrintStream -import java.net.ServerSocket +import java.nio.file.Path +import kotlin.io.path.deleteIfExists +import kotlin.io.path.writeText -class GradleJob{ +// Starts a gradle job to run the sketch +class GradleJob( + vararg val tasks: String, + val workingDir: Path, + val sketch: Sketch, + val editor: Editor? = null, +){ enum class State{ NONE, BUILDING, @@ -33,8 +48,9 @@ class GradleJob{ DONE } - var service: GradleService? = null - var configure: BuildLauncher.() -> Unit = {} + val debugPort = (30_000..60_000).random() + val logPort = debugPort + 1 + val errPort = logPort + 1 val state = mutableStateOf(State.NONE) val vm = mutableStateOf(null) @@ -44,70 +60,177 @@ class GradleJob{ private val scope = CoroutineScope(Dispatchers.IO) private val cancel = GradleConnector.newCancellationTokenSource() - fun start() { - val folder = service?.sketch?.folder ?: throw IllegalStateException("Sketch folder is not set") - launchJob { - try { - state.value = State.BUILDING - service?.editor?.statusMessage("Connecting to Gradle", EditorStatus.NOTICE) + // All the configuration for the gradle build + private fun BuildLauncher.setupGradle(extraArguments: List = listOf()) { + val copy = sketch.isReadOnly || sketch.isUntitled + + val sketchFolder = if(copy) workingDir.resolve("sketch").toFile() else sketch.folder + + if(copy){ + // If the sketch is read-only, we copy it to the working directory + // This allows us to run the sketch without modifying the original files + sketch.folder.copyRecursively(sketchFolder, overwrite = true) + } + // Save the unsaved code into the working directory for gradle to compile + val unsaved = sketch.code + .map { code -> + val file = workingDir.resolve("unsaved/${code.fileName}") + file.parent.toFile().mkdirs() + // If tab is marked modified save it to the working directory + // Otherwise delete the file so we don't compile with old code + if(code.isModified){ + file.writeText(code.documentText) + }else{ + file.deleteIfExists() + } + return@map code.fileName + } + // Collect the variables to pass to gradle + val variables = mapOf( + "group" to System.getProperty("processing.group", "org.processing"), + "version" to getVersionName(), + "sketchFolder" to sketchFolder, + "sketchbook" to getSketchbookFolder(), + "workingDir" to workingDir.toAbsolutePath().toString(), + "settings" to getSettingsFolder().absolutePath.toString(), + "unsaved" to unsaved.joinToString(","), + "debugPort" to debugPort.toString(), + "logPort" to logPort.toString(), + "errPort" to errPort.toString(), + "fullscreen" to System.getProperty("processing.fullscreen", "false").equals("true"), + "display" to 1, // TODO: Implement + "external" to true, + "location" to null, // TODO: Implement + "editor.location" to editor?.location?.let { "${it.x},${it.y}" }, + //"awt.disable" to false, + //"window.color" to "0xFF000000", // TODO: Implement + //"stop.color" to "0xFF000000", // TODO: Implement + "stop.hide" to false, // TODO: Implement + ) + val repository = getContentFile("repository").absolutePath.replace("""\""", """\\""") + // Create the init.gradle.kts file in the working directory + // This allows us to run the gradle plugin that has been bundled with the editor + val initGradle = workingDir.resolve("init.gradle.kts").apply { + val content = """ + beforeSettings{ + pluginManagement { + repositories { + maven("$repository") + gradlePluginPortal() + } + } + } + allprojects{ + repositories { + maven("$repository") + mavenCentral() + } + } + """.trimIndent() + + writeText(content) + } + // Create the build.gradle.kts file in the sketch folder + val buildGradle = sketchFolder.resolve("build.gradle.kts") + val generate = buildGradle.let { + if(!it.exists()) return@let true + + val contents = it.readText() + if(!contents.contains("@processing-auto-generated")) return@let false + + val version = contents.substringAfter("version=").substringBefore("\n") + if(version != getVersionName()) return@let true + + val modeTitle = contents.substringAfter("mode=").substringBefore(" ") + if(sketch.mode.title != modeTitle) return@let true + + return@let DEBUG + } + if (generate) { + Messages.log("build.gradle.kts outdated or not found in ${sketch.folder}, creating one") + val header = """ + // @processing-auto-generated mode=${sketch.mode.title} version=${getVersionName()} + // + """.trimIndent() + + val instructions = text("gradle.instructions") + .split("\n") + .joinToString("\n") { "// $it" } + + val configuration = """ + plugins{ + id("org.processing.java") version "${getVersionName()}" + } + """.trimIndent() + val content = "${header}\n${instructions}\n\n${configuration}" + buildGradle.writeText(content) + } + // Create and empty settings.gradle.kts file in the sketch folder + val settingsGradle = sketchFolder.resolve("settings.gradle.kts") + if (!settingsGradle.exists()) { + settingsGradle.createNewFile() + } + // Collect the arguments to pass to gradle + val arguments = mutableListOf("--init-script", initGradle.toAbsolutePath().toString()) + // Hide Gradle output from the console if not in debug mode + if(!DEBUG) arguments += "--quiet" + + if(copy) arguments += listOf("--project-dir", sketchFolder.absolutePath) + + arguments += variables.entries + .filter { it.value != null } + .map { "-Pprocessing.${it.key}=${it.value}" } + + arguments += extraArguments + + withArguments(*arguments.toTypedArray()) + + forTasks(*tasks) + + // TODO: Instead of shipping Processing with a build-in JDK we should download the JDK through Gradle + setJavaHome(Platform.getJavaHome()) + withCancellationToken(cancel.token()) + } + + fun start() { + launchJob { + handleExceptions { + state.value = State.BUILDING + + // Connect Gradle, configure the build and run it GradleConnector.newConnector() - .forProjectDirectory(folder) + .forProjectDirectory(sketch.folder) .apply { + editor?.statusMessage("Connecting to Gradle", EditorStatus.NOTICE) // TODO: Remove when switched to classic confinement within Snap - if(System.getenv("SNAP_USER_COMMON") != null){ - useGradleUserHomeDir(Platform.getSettingsFolder().resolve("gradle")) + if (System.getenv("SNAP_USER_COMMON") != null) { + useGradleUserHomeDir(getSettingsFolder().resolve("gradle")) } } .connect() .apply { - service?.editor?.statusMessage("Building sketch", EditorStatus.NOTICE) + editor?.statusMessage("Building sketch", EditorStatus.NOTICE) } .newBuild() .apply { - configure() - withCancellationToken(cancel.token()) - addStateListener() - addDebugging() - addLogserver() - if(Base.DEBUG) { + if (DEBUG) { setStandardOutput(System.out) setStandardError(System.err) } - run() + + setupGradle() + + addStateListener() + addLogserver() + addDebugging() + } - }catch (e: Exception){ - val causesList = mutableListOf() - var cause: Throwable? = e - while (cause != null && cause.cause != cause) { - causesList.add(cause) - cause = cause.cause - } - - val errors = causesList.joinToString("\n") { it.message ?: "Unknown error" } - - val skip = listOf(BuildCancelledException::class) - - if (skip.any { it.isInstance(e) }) { - Messages.log("Gradle job error: $errors") - return@launchJob - } - - if(state.value == State.RUNNING){ - Messages.log("Gradle job error: $errors") - return@launchJob - } - - // An error occurred during the build process - - System.err.println(errors) - service?.editor?.statusError(causesList.last().message) - }finally { - state.value = State.DONE - vm.value = null + .run() } } } + fun launchJob(block: suspend CoroutineScope.() -> Unit){ val job = scope.launch { block() } jobs.add(job) @@ -118,29 +241,66 @@ class GradleJob{ jobs.forEach(Job::cancel) } + // Handle exception thrown by Gradle + private fun handleExceptions(action: () -> Unit){ + try{ + action() + }catch (e: Exception){ + val causesList = mutableListOf() + var cause: Throwable? = e + + while (cause != null && cause.cause != cause) { + causesList.add(cause) + cause = cause.cause + } + + val errors = causesList.joinToString("\n") { it.message ?: "Unknown error" } + + val skip = listOf(BuildCancelledException::class) + + if (skip.any { it.isInstance(e) }) { + Messages.log("Gradle job error: $errors") + return + } + + if(state.value == State.RUNNING){ + Messages.log("Gradle job error: $errors") + return + } + + // An error occurred during the build process + + System.err.println(errors) + editor?.statusError(causesList.last().message) + }finally { + state.value = State.DONE + vm.value = null + } + } + private fun BuildLauncher.addStateListener(){ addProgressListener(ProgressListener { event -> if(event is TaskStartEvent) { - service?.editor?.statusMessage("Running task: ${event.descriptor.name}", EditorStatus.NOTICE) + editor?.statusMessage("Running task: ${event.descriptor.name}", EditorStatus.NOTICE) when(event.descriptor.name) { ":run" -> { state.value = State.RUNNING Messages.log("Start run") - service?.editor?.toolbar?.activateRun() + editor?.toolbar?.activateRun() } } } if(event is TaskFinishEvent) { if(event.result is TaskSuccessResult){ - service?.editor?.statusMessage("Finished task ${event.descriptor.name}", EditorStatus.NOTICE) + editor?.statusMessage("Finished task ${event.descriptor.name}", EditorStatus.NOTICE) } when(event.descriptor.name){ ":run"->{ state.value = State.DONE - service?.editor?.toolbar?.deactivateRun() - service?.editor?.toolbar?.deactivateStop() + editor?.toolbar?.deactivateRun() + editor?.toolbar?.deactivateStop() } } } @@ -168,7 +328,7 @@ class GradleJob{ */ val error = event.definition.id.displayName - service?.editor?.statusError(error) + editor?.statusError(error) System.err.println("Problem: $error") state.value = State.ERROR @@ -183,32 +343,12 @@ class GradleJob{ }) } - fun addLogserver(){ + fun BuildLauncher.addLogserver(){ launchJob { - startLogServer(service?.logPort ?: 5006, System.out) + startLogServer(logPort, System.out) } launchJob{ - startLogServer(service?.errPort ?: 5007, System.err) - } - } - fun startLogServer(port: Int, target: PrintStream){ - val server = ServerSocket(port) - Messages.log("Log server started on port $port") - val client = server.accept() - Messages.log("Log server client connected") - - val reader = client.getInputStream().bufferedReader() - try { - reader.forEachLine { line -> - if (line.isNotBlank()) { - target.println(line) - } - } - } catch (e: Exception) { - Messages.log("Error while reading from log server: ${e.message}") - } finally { - client.close() - server.close() + startLogServer(errPort, System.err) } } @@ -218,9 +358,9 @@ class GradleJob{ if (event.descriptor.name != ":run") return@ProgressListener launchJob { - val debugger = Debugger.connect(service?.debugPort) ?: return@launchJob + val debugger = Debugger.connect(debugPort) ?: return@launchJob vm.value = debugger - val exceptions = Exceptions(debugger, service?.editor) + val exceptions = Exceptions(debugger, editor) exceptions.listen() } diff --git a/app/src/processing/app/gradle/GradleService.kt b/app/src/processing/app/gradle/GradleService.kt index 2f18f4301..908518f0e 100644 --- a/app/src/processing/app/gradle/GradleService.kt +++ b/app/src/processing/app/gradle/GradleService.kt @@ -2,22 +2,12 @@ package processing.app.gradle import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf -import org.gradle.tooling.BuildLauncher -import processing.app.Base.DEBUG -import processing.app.Base.getSketchbookFolder -import processing.app.Base.getVersionName import processing.app.Language.text -import processing.app.Messages import processing.app.Mode -import processing.app.Platform -import processing.app.Platform.getContentFile -import processing.app.Platform.getSettingsFolder import processing.app.Preferences import processing.app.Sketch import processing.app.ui.Editor import kotlin.io.path.createTempDirectory -import kotlin.io.path.deleteIfExists -import kotlin.io.path.writeText // TODO: Test offline mode, gradle seems to be included as not needed to be downloaded. // TODO: Test running examples @@ -48,10 +38,6 @@ class GradleService( val jobs = mutableStateListOf() val workingDir = createTempDirectory() - val debugPort = (30_000..60_000).random() - val logPort = debugPort + 1 - val errPort = logPort + 1 - fun run(){ startJob("run") } @@ -68,12 +54,12 @@ class GradleService( if(!active.value) return editor?.let { println(text("gradle.using_gradle")) } - val job = GradleJob() - job.service = this - job.configure = { - setupGradle() - forTasks(tasks.joinToString(" ")) - } + val job = GradleJob( + tasks = tasks, + workingDir = workingDir, + sketch = sketch ?: throw IllegalStateException("Sketch is not set"), + editor = editor + ) jobs.add(job) job.start() } @@ -82,133 +68,6 @@ class GradleService( jobs.forEach(GradleJob::cancel) } - private fun BuildLauncher.setupGradle(extraArguments: List = listOf()) { - val sketch = sketch ?: throw IllegalStateException("Sketch is not set") - val copy = sketch.isReadOnly || sketch.isUntitled - val sketchFolder = if(copy) workingDir.resolve("sketch").toFile() else sketch.folder - if(copy){ - // If the sketch is read-only, we copy it to the working directory - // This allows us to run the sketch without modifying the original files - sketch.folder.copyRecursively(sketchFolder, overwrite = true) - } - // Save the unsaved code into the working directory for gradle to compile - val unsaved = sketch.code - .map { code -> - val file = workingDir.resolve("unsaved/${code.fileName}") - file.parent.toFile().mkdirs() - // If tab is marked modified save it to the working directory - // Otherwise delete the file so we don't compile with old code - if(code.isModified){ - file.writeText(code.documentText) - }else{ - file.deleteIfExists() - } - return@map code.fileName - } - // Collect the variables to pass to gradle - val variables = mapOf( - "group" to System.getProperty("processing.group", "org.processing"), - "version" to getVersionName(), - "sketchFolder" to sketchFolder, - "sketchbook" to getSketchbookFolder(), - "workingDir" to workingDir.toAbsolutePath().toString(), - "settings" to getSettingsFolder().absolutePath.toString(), - "unsaved" to unsaved.joinToString(","), - "debugPort" to debugPort.toString(), - "logPort" to logPort.toString(), - "errPort" to errPort.toString(), - "fullscreen" to System.getProperty("processing.fullscreen", "false").equals("true"), - "display" to 1, // TODO: Implement - "external" to true, - "location" to null, // TODO: Implement - "editor.location" to editor?.location?.let { "${it.x},${it.y}" }, - //"awt.disable" to false, - //"window.color" to "0xFF000000", // TODO: Implement - //"stop.color" to "0xFF000000", // TODO: Implement - "stop.hide" to false, // TODO: Implement - ) - val repository = getContentFile("repository").absolutePath.replace("""\""", """\\""") - // Create the init.gradle.kts file in the working directory - val initGradle = workingDir.resolve("init.gradle.kts").apply { - val content = """ - beforeSettings{ - pluginManagement { - repositories { - maven { url = uri("$repository") } - gradlePluginPortal() - } - } - } - allprojects{ - repositories { - maven { url = uri("$repository") } - mavenCentral() - } - } - """.trimIndent() - - writeText(content) - } - // Create the build.gradle.kts file in the sketch folder - val buildGradle = sketchFolder.resolve("build.gradle.kts") - val generate = buildGradle.let { - if(!it.exists()) return@let true - - val contents = it.readText() - if(!contents.contains("@processing-auto-generated")) return@let false - - val version = contents.substringAfter("version=").substringBefore("\n") - if(version != getVersionName()) return@let true - - val modeTitle = contents.substringAfter("mode=").substringBefore(" ") - if(mode.title != modeTitle) return@let true - - return@let DEBUG - } - if (generate) { - Messages.log("build.gradle.kts outdated or not found in ${sketch.folder}, creating one") - val header = """ - // @processing-auto-generated mode=${mode.title} version=${getVersionName()} - // - """.trimIndent() - - val instructions = text("gradle.instructions") - .split("\n") - .joinToString("\n") { "// $it" } - - val configuration = """ - - plugins{ - id("org.processing.java") version "${getVersionName()}" - } - """.trimIndent() - val content = "${header}\n${instructions}\n${configuration}" - buildGradle.writeText(content) - } - // Create the settings.gradle.kts file in the sketch folder - val settingsGradle = sketchFolder.resolve("settings.gradle.kts") - if (!settingsGradle.exists()) { - settingsGradle.createNewFile() - } - // Collect the arguments to pass to gradle - val arguments = mutableListOf("--init-script", initGradle.toAbsolutePath().toString()) - if (!DEBUG) arguments.add("--quiet") - if(copy){ - arguments += listOf("--project-dir", sketchFolder.absolutePath) - } - - arguments.addAll(variables.entries - .filter { it.value != null } - .map { "-Pprocessing.${it.key}=${it.value}" } - ) - arguments.addAll(extraArguments) - - withArguments(*arguments.toTypedArray()) - - // TODO: Instead of shipping Processing with a build-in JDK we should download the JDK through Gradle - setJavaHome(Platform.getJavaHome()) - } - // Hooks for java to check if the Gradle service is running since mutableStateOf is not accessible in java fun getEnabled(): Boolean { return active.value diff --git a/app/src/processing/app/gradle/Log.kt b/app/src/processing/app/gradle/Log.kt new file mode 100644 index 000000000..30ca070cf --- /dev/null +++ b/app/src/processing/app/gradle/Log.kt @@ -0,0 +1,30 @@ +package processing.app.gradle + +import processing.app.Messages +import java.io.PrintStream +import java.net.ServerSocket + +class Log{ + companion object{ + fun startLogServer(port: Int, target: PrintStream){ + val server = ServerSocket(port) + Messages.Companion.log("Log server started on port $port") + val client = server.accept() + Messages.Companion.log("Log server client connected") + + val reader = client.getInputStream().bufferedReader() + try { + reader.forEachLine { line -> + if (line.isNotBlank()) { + target.println(line) + } + } + } catch (e: Exception) { + Messages.Companion.log("Error while reading from log server: ${e.message}") + } finally { + client.close() + server.close() + } + } + } +}