diff --git a/build.gradle.kts b/build.gradle.kts index 66f4e32..dad97b8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,11 @@ plugins { alias(libs.plugins.spotless) } +allprojects { + group = "com.composables" + version = "0.1.0" +} + configure { kotlinGradle { target("*.gradle.kts") diff --git a/devtools/gradle-plugin/build.gradle.kts b/devtools/gradle-plugin/build.gradle.kts index a29da47..0cdc456 100644 --- a/devtools/gradle-plugin/build.gradle.kts +++ b/devtools/gradle-plugin/build.gradle.kts @@ -22,8 +22,13 @@ plugins { `kotlin-dsl` `java-gradle-plugin` + `maven-publish` } +group = "com.composables" + +version = "0.1.0" + java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } dependencies { diff --git a/devtools/gradle-plugin/src/main/kotlin/com/composables/devtools/gradle/ComposablesDevToolsPlugin.kt b/devtools/gradle-plugin/src/main/kotlin/com/composables/devtools/gradle/ComposablesDevToolsPlugin.kt index b488d2d..babded7 100644 --- a/devtools/gradle-plugin/src/main/kotlin/com/composables/devtools/gradle/ComposablesDevToolsPlugin.kt +++ b/devtools/gradle-plugin/src/main/kotlin/com/composables/devtools/gradle/ComposablesDevToolsPlugin.kt @@ -7,8 +7,10 @@ import org.gradle.api.file.DirectoryProperty import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.api.tasks.Input +import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.create import org.gradle.kotlin.dsl.named @@ -21,8 +23,10 @@ import javax.inject.Inject abstract class ComposablesDevToolsExtension @Inject constructor(objects: ObjectFactory) { val appComposable: Property = objects.property(String::class.java) + val runtimeDependencyPath: Property = objects.property(String::class.java).convention(DEV_TOOLS_RUNTIME_PATH) } +@DisableCachingByDefault(because = "The task writes a generated source file from a small extension value.") abstract class GenerateComposablesDevToolsMainTask : DefaultTask() { @get:Input abstract val appComposable: Property @@ -76,70 +80,74 @@ abstract class GenerateComposablesDevToolsMainTask : DefaultTask() { import com.composables.devtools.oppositeDevToolsLayoutDirection import com.composables.devtools.requestDevToolsHotReload import com.composables.devtools.rotated + import com.composables.devtools.startDevToolsAutomationServer - fun main() = application { - val windowState = rememberWindowState(width = 960.dp, height = 860.dp) - var selectedDevice by remember { mutableStateOf(DevToolsDevices.Desktop) } - var selectedOrientation by remember { mutableStateOf(DevToolsOrientation.Portrait) } - var selectedLayoutDirection by remember { mutableStateOf(LayoutDirection.Ltr) } - var selectedZoom by remember { mutableStateOf(DevToolsZoom.Default) } - var toolbarVisible by remember { mutableStateOf(true) } - var saveScreenshotRequest by remember { mutableStateOf(0) } - var copyScreenshotRequest by remember { mutableStateOf(0) } - - Window( - onCloseRequest = ::exitApplication, - state = windowState, - title = "Composables Dev Tools", - onKeyEvent = { event -> - val shortcutDevice = deviceForDevToolsShortcut(event) - if (shortcutDevice != null) { - selectedDevice = shortcutDevice - true - } else if (isDevToolsHotReloadShortcut(event) && isDevToolsHotReloadAvailable()) { - runCatching { requestDevToolsHotReload() } - true - } else if (isDevToolsScreenshotSaveShortcut(event)) { - saveScreenshotRequest++ - true - } else if (isDevToolsScreenshotCopyShortcut(event)) { - copyScreenshotRequest++ - true - } else if (isDevToolsRotationShortcut(event) && selectedDevice.canRotate) { - selectedOrientation = selectedOrientation.rotated() - true - } else if (isDevToolsLayoutDirectionShortcut(event)) { - selectedLayoutDirection = selectedLayoutDirection.oppositeDevToolsLayoutDirection() - true - } else if (isDevToolsToolbarShortcut(event)) { - toolbarVisible = !toolbarVisible - true - } else { - val shortcutZoom = devToolsZoomForShortcut(event, selectedZoom) - if (shortcutZoom != null) { - selectedZoom = shortcutZoom + fun main() { + startDevToolsAutomationServer() + application { + val windowState = rememberWindowState(width = 960.dp, height = 860.dp) + var selectedDevice by remember { mutableStateOf(DevToolsDevices.Desktop) } + var selectedOrientation by remember { mutableStateOf(DevToolsOrientation.Portrait) } + var selectedLayoutDirection by remember { mutableStateOf(LayoutDirection.Ltr) } + var selectedZoom by remember { mutableStateOf(DevToolsZoom.Default) } + var toolbarVisible by remember { mutableStateOf(true) } + var saveScreenshotRequest by remember { mutableStateOf(0) } + var copyScreenshotRequest by remember { mutableStateOf(0) } + + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "Composables Dev Tools", + onKeyEvent = { event -> + val shortcutDevice = deviceForDevToolsShortcut(event) + if (shortcutDevice != null) { + selectedDevice = shortcutDevice + true + } else if (isDevToolsHotReloadShortcut(event) && isDevToolsHotReloadAvailable()) { + runCatching { requestDevToolsHotReload() } + true + } else if (isDevToolsScreenshotSaveShortcut(event)) { + saveScreenshotRequest++ + true + } else if (isDevToolsScreenshotCopyShortcut(event)) { + copyScreenshotRequest++ + true + } else if (isDevToolsRotationShortcut(event) && selectedDevice.canRotate) { + selectedOrientation = selectedOrientation.rotated() + true + } else if (isDevToolsLayoutDirectionShortcut(event)) { + selectedLayoutDirection = selectedLayoutDirection.oppositeDevToolsLayoutDirection() + true + } else if (isDevToolsToolbarShortcut(event)) { + toolbarVisible = !toolbarVisible true } else { - false + val shortcutZoom = devToolsZoomForShortcut(event, selectedZoom) + if (shortcutZoom != null) { + selectedZoom = shortcutZoom + true + } else { + false + } } - } - }, - ) { - DevTools( - initialDevice = DevToolsDevices.Desktop, - selectedDevice = selectedDevice, - onDeviceSelected = { selectedDevice = it }, - selectedOrientation = selectedOrientation, - onOrientationSelected = { selectedOrientation = it }, - selectedLayoutDirection = selectedLayoutDirection, - onLayoutDirectionSelected = { selectedLayoutDirection = it }, - selectedZoom = selectedZoom, - onZoomSelected = { selectedZoom = it }, - showControls = toolbarVisible, - saveScreenshotRequest = saveScreenshotRequest, - copyScreenshotRequest = copyScreenshotRequest, + }, ) { - $appComposableCall() + DevTools( + initialDevice = DevToolsDevices.Desktop, + selectedDevice = selectedDevice, + onDeviceSelected = { selectedDevice = it }, + selectedOrientation = selectedOrientation, + onOrientationSelected = { selectedOrientation = it }, + selectedLayoutDirection = selectedLayoutDirection, + onLayoutDirectionSelected = { selectedLayoutDirection = it }, + selectedZoom = selectedZoom, + onZoomSelected = { selectedZoom = it }, + showControls = toolbarVisible, + saveScreenshotRequest = saveScreenshotRequest, + copyScreenshotRequest = copyScreenshotRequest, + ) { + $appComposableCall() + } } } } @@ -152,8 +160,10 @@ class ComposablesDevToolsPlugin : Plugin { override fun apply(project: Project) { val extension = project.extensions.create("composablesDevTools") val generatedSourceDirectory = project.layout.buildDirectory.dir("generated/composablesDevTools/kotlin") + val mcpRuntimeConfiguration = project.configurations.create("devToolsMcpRuntime") project.pluginManager.apply("org.jetbrains.compose.hot-reload") + project.dependencies.add(mcpRuntimeConfiguration.name, DEV_TOOLS_MCP_RUNTIME_COORDINATES) val generateMain = project.tasks.register("generateDevToolsMain") { appComposable.set(extension.appComposable) @@ -175,18 +185,43 @@ class ComposablesDevToolsPlugin : Plugin { devCompilation.defaultSourceSet.apply { kotlin.srcDir(generateMain.map { it.outputDirectory }) dependencies { - implementation(project.dependencies.project(mapOf("path" to DEV_TOOLS_RUNTIME_PATH))) + val runtimeDependencyPath = extension.runtimeDependencyPath.get() + val runtimeDependency = if (project.rootProject.findProject(runtimeDependencyPath) == null) { + DEV_TOOLS_RUNTIME_COORDINATES + } else { + project.dependencies.project(mapOf("path" to runtimeDependencyPath)) + } + implementation(runtimeDependency) implementation(ComposePlugin.Dependencies(project).desktop.currentOs) } } - project.tasks.register("runDevTools") { + val runDevTools = project.tasks.register("runDevTools") { group = "compose dev tools" description = "Runs the configured Compose app composable inside the dev tools host with automatic hot reload." dependsOn(generateMain) compilation.set(devCompilation) mainClass.set(DEV_TOOLS_MAIN_CLASS) isAutoReloadEnabled.set(true) + systemProperty(DEV_TOOLS_PORT_FILE_PROPERTY, DEV_TOOLS_PORT_FILE_PATH) + runHotReloadDevToolsHeadless(project) + } + + project.gradle.projectsEvaluated { + runDevTools.configure { + runHotReloadDevToolsHeadless(project) + } + } + + project.tasks.register("runDevToolsMcpServer") { + group = "compose dev tools" + description = "Runs an MCP server for controlling the running Compose dev tools host." + classpath = mcpRuntimeConfiguration + mainClass.set("com.composables.devtools.DevToolsMcpServer_jvmKt") + systemProperty(DEV_TOOLS_PORT_FILE_PROPERTY, DEV_TOOLS_PORT_FILE_PATH) + standardInput = System.`in` + standardOutput = System.out + errorOutput = System.err } project.tasks.register("checkDevToolsCompatibility") { @@ -200,5 +235,29 @@ class ComposablesDevToolsPlugin : Plugin { } } +private fun ComposeHotRun.runHotReloadDevToolsHeadless(project: Project) { + val enabled = project.providers.provider { true } + jvmArgumentProviders + .filter { arguments -> arguments.javaClass.name == HOT_RELOAD_ARGUMENTS_CLASS_NAME } + .forEach { arguments -> + arguments.invokeHotReloadArgumentSetter("setDevToolsEnabled", enabled) + arguments.invokeHotReloadArgumentSetter("setDevToolsHeadless", enabled) + } +} + +private fun Any.invokeHotReloadArgumentSetter(name: String, value: Any) { + val setter = + javaClass.methods.firstOrNull { method -> + method.name == name && method.parameterTypes.size == 1 + } ?: error("Compose Hot Reload no longer exposes $name on ${javaClass.name}.") + + setter.invoke(this, value) +} + private const val DEV_TOOLS_RUNTIME_PATH = ":devtools:runtime" +private const val DEV_TOOLS_RUNTIME_COORDINATES = "com.composables:runtime:0.1.0" +private const val DEV_TOOLS_MCP_RUNTIME_COORDINATES = "com.composables:runtime-jvm:0.1.0" private const val DEV_TOOLS_MAIN_CLASS = "com.composables.devtools.generated.DevToolsMainKt" +private const val DEV_TOOLS_PORT_FILE_PROPERTY = "composables.devtools.portFile" +private const val DEV_TOOLS_PORT_FILE_PATH = "/tmp/composables-devtools.port" +private const val HOT_RELOAD_ARGUMENTS_CLASS_NAME = "org.jetbrains.compose.reload.gradle.ComposeHotReloadArguments" diff --git a/devtools/insets/build.gradle.kts b/devtools/insets/build.gradle.kts index 0678087..675e93d 100644 --- a/devtools/insets/build.gradle.kts +++ b/devtools/insets/build.gradle.kts @@ -23,6 +23,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) + `maven-publish` } java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } diff --git a/devtools/runtime/build.gradle.kts b/devtools/runtime/build.gradle.kts index 528de38..b45fd1b 100644 --- a/devtools/runtime/build.gradle.kts +++ b/devtools/runtime/build.gradle.kts @@ -23,6 +23,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose) alias(libs.plugins.compose.compiler) + `maven-publish` } java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } diff --git a/devtools/runtime/src/commonMain/kotlin/com/composables/devtools/DevTools.kt b/devtools/runtime/src/commonMain/kotlin/com/composables/devtools/DevTools.kt index e15ddc5..b20b9e1 100644 --- a/devtools/runtime/src/commonMain/kotlin/com/composables/devtools/DevTools.kt +++ b/devtools/runtime/src/commonMain/kotlin/com/composables/devtools/DevTools.kt @@ -150,6 +150,7 @@ import kotlin.math.abs import kotlin.math.roundToInt import kotlin.math.roundToLong import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.async import kotlinx.coroutines.launch data class DevToolsDevice( @@ -894,6 +895,12 @@ private fun DevToolsStage( RunOnRequestChange(saveScreenshotRequest) { saveCurrentDevToolsScreenshot(screenshotLayer) } RunOnRequestChange(copyScreenshotRequest) { copyCurrentDevToolsScreenshot(screenshotLayer) } + LaunchedEffect(screenshotLayer) { + registerDevToolsScreenshotProvider { + coroutineScope.async { screenshotLayer.toImageBitmap() }.await() + } + } + LaunchedEffect(device.id, device.canRotate, orientation) { suspend fun animateFrameRotationTo( targetRotation: Float, diff --git a/devtools/runtime/src/commonMain/kotlin/com/composables/devtools/DevToolsScreenshot.kt b/devtools/runtime/src/commonMain/kotlin/com/composables/devtools/DevToolsScreenshot.kt index 4bfcc3c..6581150 100644 --- a/devtools/runtime/src/commonMain/kotlin/com/composables/devtools/DevToolsScreenshot.kt +++ b/devtools/runtime/src/commonMain/kotlin/com/composables/devtools/DevToolsScreenshot.kt @@ -26,3 +26,5 @@ import androidx.compose.ui.graphics.ImageBitmap expect fun saveDevToolsScreenshot(image: ImageBitmap) expect fun copyDevToolsScreenshotToClipboard(image: ImageBitmap) + +expect fun registerDevToolsScreenshotProvider(provider: (suspend () -> ImageBitmap)?) diff --git a/devtools/runtime/src/jvmMain/kotlin/com/composables/devtools/DevToolsAutomationServer.jvm.kt b/devtools/runtime/src/jvmMain/kotlin/com/composables/devtools/DevToolsAutomationServer.jvm.kt new file mode 100644 index 0000000..bb3f82e --- /dev/null +++ b/devtools/runtime/src/jvmMain/kotlin/com/composables/devtools/DevToolsAutomationServer.jvm.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Composable Horizons + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.composables.devtools + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import java.io.File +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.concurrent.Executors + +private var devToolsAutomationServer: HttpServer? = null + +fun startDevToolsAutomationServer( + portFile: File = defaultDevToolsAutomationPortFile(), +) { + if (devToolsAutomationServer != null) return + + val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) + server.createContext("/status") { exchange -> + exchange.respondText("""{"ready":${captureDevToolsScreenshotPng() != null}}""") + } + server.createContext("/screenshot") { exchange -> + val screenshot = captureDevToolsScreenshotPng() + if (screenshot == null) { + exchange.respondText("""{"error":"screenshot_not_ready"}""", code = 409) + } else { + exchange.responseHeaders.add("Content-Type", "image/png") + exchange.sendResponseHeaders(200, screenshot.size.toLong()) + exchange.responseBody.use { it.write(screenshot) } + } + } + server.executor = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "Composables DevTools Automation").apply { isDaemon = true } + } + server.start() + + portFile.parentFile?.mkdirs() + portFile.writeText("${server.address.port}\n") + devToolsAutomationServer = server +} + +internal fun defaultDevToolsAutomationPortFile(): File { + val configuredPath = System.getProperty("composables.devtools.portFile") + return if (configuredPath.isNullOrBlank()) { + File(System.getProperty("java.io.tmpdir"), "composables-devtools.port") + } else { + File(configuredPath) + } +} + +private fun HttpExchange.respondText(text: String, code: Int = 200) { + val bytes = text.toByteArray(Charsets.UTF_8) + responseHeaders.add("Content-Type", "application/json; charset=utf-8") + sendResponseHeaders(code, bytes.size.toLong()) + responseBody.use { it.write(bytes) } +} diff --git a/devtools/runtime/src/jvmMain/kotlin/com/composables/devtools/DevToolsMcpServer.jvm.kt b/devtools/runtime/src/jvmMain/kotlin/com/composables/devtools/DevToolsMcpServer.jvm.kt new file mode 100644 index 0000000..eb0d04d --- /dev/null +++ b/devtools/runtime/src/jvmMain/kotlin/com/composables/devtools/DevToolsMcpServer.jvm.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026 Composable Horizons + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.composables.devtools + +import java.io.BufferedInputStream +import java.io.File +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.util.Base64 + +fun main() { + DevToolsMcpServer().run() +} + +private class DevToolsMcpServer { + private val input = BufferedInputStream(System.`in`) + private val output = System.out + private val httpClient = HttpClient.newHttpClient() + + fun run() { + while (true) { + val message = readMessage() ?: return + val id = message.jsonValue("id") + val method = message.jsonValue("method") ?: continue + + when (method) { + "initialize" -> + respond( + id = id, + result = + """ + {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"composables-devtools","version":"0.1.0"}} + """ + .trimIndent(), + ) + "notifications/initialized" -> continue + "tools/list" -> respond(id = id, result = toolsListJson) + "tools/call" -> handleToolCall(id, message) + "ping" -> respond(id = id, result = "{}") + else -> + if (id != null) + respondError(id = id, code = -32601, message = "Unknown method: $method") + } + } + } + + private fun handleToolCall(id: String?, message: String) { + when (message.jsonValue("name")) { + "status" -> { + val ready = runCatching { readStatusReady() }.getOrDefault(false) + respond(id = id, result = """{"content":[{"type":"text","text":"{\"ready\":$ready}"}]}""") + } + "take_screenshot" -> { + val screenshot = + runCatching { requestScreenshot() } + .getOrElse { error -> + respondError( + id = id, code = -32000, message = error.message ?: "Screenshot failed") + return + } + val saveTo = message.jsonValue("save_to") + if (saveTo != null) File(saveTo).writeBytes(screenshot) + val encoded = Base64.getEncoder().encodeToString(screenshot) + val savedText = + if (saveTo == null) { + "Screenshot captured" + } else { + "Screenshot saved to: $saveTo" + } + respond( + id = id, + result = + """ + {"content":[{"type":"text","text":"${savedText.escapeJson()}"},{"type":"image","data":"$encoded","mimeType":"image/png"}]} + """ + .trimIndent(), + ) + } + else -> respondError(id = id, code = -32602, message = "Unknown tool") + } + } + + private fun readStatusReady(): Boolean { + val port = readPort() + val request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:$port/status")).GET().build() + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + return response.statusCode() == 200 && response.body().contains(""""ready":true""") + } + + private fun requestScreenshot(): ByteArray { + val port = readPort() + val request = + HttpRequest.newBuilder(URI.create("http://127.0.0.1:$port/screenshot")).GET().build() + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()) + check(response.statusCode() == 200) { "DevTools screenshot is not ready" } + return response.body() + } + + private fun readPort(): Int { + val portFile = defaultDevToolsAutomationPortFile() + check(portFile.exists()) { "DevTools automation port file does not exist: ${portFile.path}" } + return portFile.readText().trim().toInt() + } + + private fun readMessage(): String? { + var contentLength: Int? = null + while (true) { + val line = input.readAsciiLine() ?: return null + if (line.isEmpty()) break + val separator = line.indexOf(':') + if (separator > 0 && + line.substring(0, separator).equals("Content-Length", ignoreCase = true)) { + contentLength = line.substring(separator + 1).trim().toInt() + } + } + val length = contentLength ?: return null + return input.readNBytes(length).toString(Charsets.UTF_8) + } + + private fun respond(id: String?, result: String) { + write("""{"jsonrpc":"2.0","id":${id ?: "null"},"result":$result}""") + } + + private fun respondError(id: String?, code: Int, message: String) { + write( + """{"jsonrpc":"2.0","id":${id ?: "null"},"error":{"code":$code,"message":"${message.escapeJson()}"}}""") + } + + private fun write(message: String) { + val bytes = message.toByteArray(Charsets.UTF_8) + output.write("Content-Length: ${bytes.size}\r\n\r\n".toByteArray(Charsets.US_ASCII)) + output.write(bytes) + output.flush() + } +} + +private fun BufferedInputStream.readAsciiLine(): String? { + val bytes = mutableListOf() + while (true) { + val value = read() + if (value == -1) + return if (bytes.isEmpty()) null else bytes.toByteArray().toString(Charsets.US_ASCII) + if (value == '\n'.code) { + if (bytes.lastOrNull() == '\r'.code.toByte()) bytes.removeAt(bytes.lastIndex) + return bytes.toByteArray().toString(Charsets.US_ASCII) + } + bytes += value.toByte() + } +} + +private fun String.jsonValue(name: String): String? { + val pattern = Regex(""""${Regex.escape(name)}"\s*:\s*("([^"\\]*(?:\\.[^"\\]*)*)"|-?\d+)""") + val match = pattern.find(this) ?: return null + val raw = match.groupValues[1] + return if (raw.startsWith("\"")) raw.substring(1, raw.length - 1).unescapeJson() else raw +} + +private fun String.escapeJson(): String { + return buildString { + for (char in this@escapeJson) { + when (char) { + '\\' -> append("""\\""") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(char) + } + } + } +} + +private fun String.unescapeJson(): String { + return replace("\\\"", "\"").replace("\\\\", "\\") +} + +private val toolsListJson = + """ + {"tools":[ + {"name":"status","description":"Reports whether the Composables DevTools host is reachable and has a screenshot ready.","inputSchema":{"type":"object","properties":{}}}, + {"name":"take_screenshot","description":"Captures the current Composables DevTools preview as a PNG without using desktop screen capture.","inputSchema":{"type":"object","properties":{"save_to":{"type":"string","description":"Optional absolute path where the PNG should be written."}}}} + ]} + """ + .trimIndent() diff --git a/devtools/runtime/src/jvmMain/kotlin/com/composables/devtools/DevToolsScreenshot.jvm.kt b/devtools/runtime/src/jvmMain/kotlin/com/composables/devtools/DevToolsScreenshot.jvm.kt index 5866c92..d8f4153 100644 --- a/devtools/runtime/src/jvmMain/kotlin/com/composables/devtools/DevToolsScreenshot.jvm.kt +++ b/devtools/runtime/src/jvmMain/kotlin/com/composables/devtools/DevToolsScreenshot.jvm.kt @@ -29,10 +29,14 @@ import java.awt.Image import java.awt.Toolkit import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.Transferable +import java.io.ByteArrayOutputStream import java.io.File import java.time.LocalDateTime import java.time.format.DateTimeFormatter import javax.imageio.ImageIO +import kotlinx.coroutines.runBlocking + +@Volatile private var devToolsScreenshotProvider: (suspend () -> ImageBitmap)? = null actual fun saveDevToolsScreenshot(image: ImageBitmap) { val dialog = @@ -53,6 +57,22 @@ actual fun copyDevToolsScreenshotToClipboard(image: ImageBitmap) { .setContents(ImageTransferable(image.toAwtImage()), null) } +actual fun registerDevToolsScreenshotProvider(provider: (suspend () -> ImageBitmap)?) { + devToolsScreenshotProvider = provider +} + +fun captureDevToolsScreenshotPng(): ByteArray? { + val provider = devToolsScreenshotProvider ?: return null + val image = runBlocking { provider() } + return image.toPngBytes() +} + +private fun ImageBitmap.toPngBytes(): ByteArray { + val output = ByteArrayOutputStream() + ImageIO.write(toAwtImage(), "png", output) + return output.toByteArray() +} + private fun File.withPngExtension(): File { return if (extension.equals("png", ignoreCase = true)) { this