mirror of
https://github.com/jlengrand/compose-multiplatform.git
synced 2026-03-10 08:11:20 +00:00
Implement first version of non-interactive preview for desktop (#803)
Implement first version of non-interactive preview for desktop
This commit is contained in:
@@ -115,3 +115,9 @@ fun Project.configureGradlePlugin(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("publishToMavenLocal") {
|
||||
for (subproject in subprojects) {
|
||||
dependsOn(subproject.tasks.named("publishToMavenLocal"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,51 @@
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
import org.gradle.api.JavaVersion
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.artifacts.dsl.DependencyHandler
|
||||
import org.gradle.api.plugins.JavaPlugin
|
||||
import org.gradle.api.tasks.testing.Test
|
||||
import org.gradle.kotlin.dsl.dependencies
|
||||
import org.gradle.kotlin.dsl.withType
|
||||
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
|
||||
import java.io.File
|
||||
|
||||
inline fun <reified T> Project.configureIfExists(fn: T.() -> Unit) {
|
||||
extensions.findByType(T::class.java)?.fn()
|
||||
}
|
||||
|
||||
val javaHomeForTests: String? = when {
|
||||
// __COMPOSE_NATIVE_DISTRIBUTIONS_MIN_JAVA_VERSION__
|
||||
JavaVersion.current() >= JavaVersion.VERSION_15 -> System.getProperty("java.home")
|
||||
else -> System.getenv("JDK_15")
|
||||
?: System.getenv("JDK_FOR_GRADLE_TESTS")
|
||||
}
|
||||
|
||||
val isWindows = DefaultNativePlatform.getCurrentOperatingSystem().isWindows
|
||||
|
||||
fun Test.configureJavaForComposeTest() {
|
||||
if (javaHomeForTests != null) {
|
||||
val executableFileName = if (isWindows) "java.exe" else "java"
|
||||
executable = File(javaHomeForTests).resolve("bin/$executableFileName").absolutePath
|
||||
} else {
|
||||
doFirst { error("Use JDK 15+ to run tests or set up JDK_15/JDK_FOR_GRADLE_TESTS env. var") }
|
||||
}
|
||||
}
|
||||
|
||||
fun Project.configureJUnit() {
|
||||
fun DependencyHandler.testImplementation(notation: Any) =
|
||||
add(JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME, notation)
|
||||
|
||||
dependencies {
|
||||
testImplementation(platform("org.junit:junit-bom:5.7.0"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
id("maven-publish")
|
||||
}
|
||||
|
||||
mavenPublicationConfig {
|
||||
displayName = "Compose Desktop Preview Runtime"
|
||||
description = "Runtime helpers for Compose Desktop Preview"
|
||||
artifactId = "compose-preview-runtime-desktop"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.jetbrains.kotlin:kotlin-stdlib")
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.preview.runtime
|
||||
|
||||
import kotlin.reflect.KProperty1
|
||||
|
||||
class ComposePreviewRunner {
|
||||
companion object {
|
||||
private const val PREVIEW_ANNOTATION_FQ_NAME = "androidx.compose.ui.tooling.desktop.preview.Preview"
|
||||
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
val classLoader = ComposePreviewRunner::class.java.classLoader
|
||||
|
||||
val previewFqName = args[0]
|
||||
val previewClassFqName = previewFqName.substringBeforeLast(".")
|
||||
val previewMethodName = previewFqName.substringAfterLast(".")
|
||||
val previewClass = classLoader.loadClass(previewClassFqName)
|
||||
val previewMethod = previewClass.methods.find { it.name == previewMethodName }
|
||||
?: error("Could not find method '$previewMethodName' in class '${previewClass.canonicalName}'")
|
||||
|
||||
val content = previewMethod.invoke(previewClass)
|
||||
val previewAnnotation = previewMethod.annotations.find { it.annotationClass.qualifiedName == PREVIEW_ANNOTATION_FQ_NAME }
|
||||
?: error("Could not find '$PREVIEW_ANNOTATION_FQ_NAME' annotation on '$previewClassFqName#$previewMethodName'")
|
||||
val environmentKClassProperty = previewAnnotation.annotationClass.members.find { it is KProperty1<*, *> && it.name == "environment" }
|
||||
as KProperty1<Any, Class<*>>
|
||||
val environmentClass = environmentKClassProperty.get(previewAnnotation)
|
||||
val previewEnvironment = environmentClass
|
||||
.getDeclaredConstructor()
|
||||
.newInstance()
|
||||
val showMethod = previewEnvironment.javaClass
|
||||
.methods.find { it.name == "show" }
|
||||
?: error("Could not find 'show' in class '${environmentClass.canonicalName}'")
|
||||
showMethod.invoke(previewEnvironment, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ dependencies {
|
||||
compileOnly(localGroovy())
|
||||
compileOnly(kotlin("gradle-plugin-api"))
|
||||
compileOnly(kotlin("gradle-plugin"))
|
||||
implementation(project(":preview-rpc"))
|
||||
|
||||
testImplementation(gradleTestKit())
|
||||
testImplementation(platform("org.junit:junit-bom:5.7.0"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
@@ -97,22 +99,13 @@ fun testGradleVersion(gradleVersion: String) {
|
||||
}
|
||||
}
|
||||
|
||||
configureJUnit()
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
}
|
||||
configureJavaForComposeTest()
|
||||
|
||||
dependsOn("publishToMavenLocal")
|
||||
systemProperty("compose.plugin.version", BuildProperties.deployVersion(project))
|
||||
|
||||
if (javaHomeForTests != null) {
|
||||
val executableFileName = if (isWindows) "java.exe" else "java"
|
||||
executable = File(javaHomeForTests).resolve("bin/$executableFileName").absolutePath
|
||||
} else {
|
||||
doFirst { error("Use JDK 15+ to run tests or set up JDK_15/JDK_FOR_GRADLE_TESTS env. var") }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
task("printAllAndroidxReplacements") {
|
||||
|
||||
@@ -115,6 +115,7 @@ class ComposePlugin : Plugin<Project> {
|
||||
val material get() = composeDependency("org.jetbrains.compose.material:material")
|
||||
val runtime get() = composeDependency("org.jetbrains.compose.runtime:runtime")
|
||||
val ui get() = composeDependency("org.jetbrains.compose.ui:ui")
|
||||
val uiTooling get() = composeDependency("org.jetbrains.compose.ui:ui-tooling")
|
||||
val materialIconsExtended get() = composeDependency("org.jetbrains.compose.material:material-icons-extended")
|
||||
val web: WebDependencies get() =
|
||||
if (ComposeBuildConfig.isComposeWithWeb) WebDependencies
|
||||
|
||||
@@ -17,8 +17,8 @@ import org.jetbrains.compose.desktop.application.dsl.Application
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.compose.desktop.application.internal.validation.validatePackageVersions
|
||||
import org.jetbrains.compose.desktop.application.tasks.*
|
||||
import org.jetbrains.compose.desktop.preview.internal.configureRunPreviewTask
|
||||
import org.jetbrains.compose.desktop.preview.tasks.AbstractRunComposePreviewTask
|
||||
import org.jetbrains.compose.desktop.preview.internal.configureConfigureDesktopPreviewTask
|
||||
import org.jetbrains.compose.desktop.preview.tasks.AbstractConfigureDesktopPreviewTask
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
|
||||
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
|
||||
import java.io.File
|
||||
@@ -158,8 +158,8 @@ internal fun Project.configurePackagingTasks(apps: Collection<Application>) {
|
||||
configureRunTask(app)
|
||||
}
|
||||
|
||||
val runPreview = project.tasks.composeTask<AbstractRunComposePreviewTask>("runComposeDesktopPreview") {
|
||||
configureRunPreviewTask(app)
|
||||
val configureDesktopPreviewTask = project.tasks.composeTask<AbstractConfigureDesktopPreviewTask>("configureDesktopPreview") {
|
||||
configureConfigureDesktopPreviewTask(app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
package org.jetbrains.compose.desktop.preview.internal
|
||||
|
||||
import org.gradle.api.Project
|
||||
import org.jetbrains.compose.composeVersion
|
||||
import org.jetbrains.compose.desktop.application.dsl.Application
|
||||
import org.jetbrains.compose.desktop.application.internal.javaHomeOrDefault
|
||||
import org.jetbrains.compose.desktop.application.internal.provider
|
||||
import org.jetbrains.compose.desktop.preview.tasks.AbstractRunComposePreviewTask
|
||||
|
||||
internal const val PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION = "composeDesktopPreviewRuntimeClasspath"
|
||||
private val COMPOSE_PREVIEW_RUNTIME_DEPENDENCY = "org.jetbrains.compose:compose-preview-runtime-desktop:$composeVersion"
|
||||
import org.jetbrains.compose.desktop.preview.tasks.AbstractConfigureDesktopPreviewTask
|
||||
|
||||
fun Project.initializePreview() {
|
||||
configurations.create(PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION).defaultDependencies { deps ->
|
||||
deps.add(dependencies.create(COMPOSE_PREVIEW_RUNTIME_DEPENDENCY))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun AbstractRunComposePreviewTask.configureRunPreviewTask(app: Application) {
|
||||
internal fun AbstractConfigureDesktopPreviewTask.configureConfigureDesktopPreviewTask(app: Application) {
|
||||
app._configurationSource?.let { configSource ->
|
||||
dependsOn(configSource.jarTaskName)
|
||||
classpath = configSource.runtimeClasspath(project)
|
||||
previewClasspath = configSource.runtimeClasspath(project)
|
||||
javaHome.set(provider { app.javaHomeOrDefault() })
|
||||
jvmArgs.set(provider { app.jvmArgs })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.jetbrains.compose.desktop.preview.tasks
|
||||
|
||||
import org.gradle.api.file.FileCollection
|
||||
import org.gradle.api.logging.Logger as GradleLogger
|
||||
import org.gradle.api.provider.ListProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.provider.Provider
|
||||
import org.gradle.api.tasks.*
|
||||
import org.jetbrains.compose.ComposeBuildConfig
|
||||
import org.jetbrains.compose.desktop.application.internal.javaExecutable
|
||||
import org.jetbrains.compose.desktop.application.internal.notNullProperty
|
||||
import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask
|
||||
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.*
|
||||
import java.io.File
|
||||
|
||||
abstract class AbstractConfigureDesktopPreviewTask : AbstractComposeDesktopTask() {
|
||||
@get:InputFiles
|
||||
internal lateinit var previewClasspath: FileCollection
|
||||
|
||||
@get:Internal
|
||||
internal val javaHome: Property<String> = objects.notNullProperty<String>().apply {
|
||||
set(providers.systemProperty("java.home"))
|
||||
}
|
||||
|
||||
// todo
|
||||
@get:Input
|
||||
@get:Optional
|
||||
internal val jvmArgs: ListProperty<String> = objects.listProperty(String::class.java)
|
||||
|
||||
@get:Input
|
||||
internal val previewTarget: Provider<String> =
|
||||
project.providers.gradleProperty("compose.desktop.preview.target")
|
||||
|
||||
@get:Input
|
||||
internal val idePort: Provider<String> =
|
||||
project.providers.gradleProperty("compose.desktop.preview.ide.port")
|
||||
|
||||
@get:InputFiles
|
||||
internal val hostClasspath = project.configurations.detachedConfiguration(
|
||||
project.dependencies.create("org.jetbrains.compose:preview-rpc:${ComposeBuildConfig.VERSION}")
|
||||
)
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val hostConfig = PreviewHostConfig(
|
||||
javaExecutable = javaExecutable(javaHome.get()),
|
||||
hostClasspath = hostClasspath.files.pathString()
|
||||
)
|
||||
val previewClasspathString = previewClasspath.files.pathString()
|
||||
|
||||
val gradleLogger = logger
|
||||
val previewLogger = GradlePreviewLoggerAdapter(gradleLogger)
|
||||
|
||||
val connection = getLocalConnectionOrNull(idePort.get().toInt(), previewLogger, onClose = {})
|
||||
if (connection != null) {
|
||||
connection.use {
|
||||
connection.sendConfigFromGradle(
|
||||
hostConfig,
|
||||
previewClasspath = previewClasspathString,
|
||||
previewFqName = previewTarget.get()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
gradleLogger.error("Could not connect to IDE")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Collection<File>.pathString(): String =
|
||||
joinToString(File.pathSeparator) { it.absolutePath }
|
||||
|
||||
private class GradlePreviewLoggerAdapter(
|
||||
private val logger: GradleLogger
|
||||
) : PreviewLogger() {
|
||||
// todo: support compose.verbose
|
||||
override val isEnabled: Boolean
|
||||
get() = logger.isDebugEnabled
|
||||
|
||||
override fun log(s: String) {
|
||||
logger.info("Compose Preview: $s")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package org.jetbrains.compose.desktop.preview.tasks
|
||||
|
||||
import org.gradle.api.file.FileCollection
|
||||
import org.gradle.api.provider.ListProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.*
|
||||
import org.jetbrains.compose.desktop.application.internal.javaExecutable
|
||||
import org.jetbrains.compose.desktop.application.internal.notNullProperty
|
||||
import org.jetbrains.compose.desktop.preview.internal.PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION
|
||||
import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask
|
||||
|
||||
abstract class AbstractRunComposePreviewTask : AbstractComposeDesktopTask() {
|
||||
@get:InputFiles
|
||||
internal lateinit var classpath: FileCollection
|
||||
|
||||
@get:InputFiles
|
||||
internal val previewRuntimeClasspath: FileCollection
|
||||
get() = project.configurations.getByName(PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION)
|
||||
|
||||
@get:Internal
|
||||
internal val javaHome: Property<String> = objects.notNullProperty<String>().apply {
|
||||
set(providers.systemProperty("java.home"))
|
||||
}
|
||||
|
||||
@get:Input
|
||||
@get:Optional
|
||||
internal val jvmArgs: ListProperty<String> = objects.listProperty(String::class.java)
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val target = project.findProperty("compose.desktop.preview.target") as String
|
||||
execOperations.javaexec { javaExec ->
|
||||
javaExec.executable = javaExecutable(javaHome.get())
|
||||
javaExec.main = "org.jetbrains.compose.desktop.preview.runtime.ComposePreviewRunner"
|
||||
javaExec.classpath = previewRuntimeClasspath + classpath
|
||||
javaExec.args = listOf(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
32
gradle-plugins/preview-rpc/build.gradle.kts
Normal file
32
gradle-plugins/preview-rpc/build.gradle.kts
Normal file
@@ -0,0 +1,32 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
id("maven-publish")
|
||||
}
|
||||
|
||||
mavenPublicationConfig {
|
||||
displayName = "Compose Preview RPC"
|
||||
description = "Compose Preview RPC"
|
||||
artifactId = "preview-rpc"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
}
|
||||
|
||||
configureJUnit()
|
||||
|
||||
tasks.test.configure {
|
||||
configureJavaForComposeTest()
|
||||
|
||||
val runtimeClasspath = configurations.runtimeClasspath
|
||||
dependsOn(runtimeClasspath)
|
||||
val jar = tasks.jar
|
||||
dependsOn(jar)
|
||||
doFirst {
|
||||
val rpcClasspath = LinkedHashSet<File>()
|
||||
rpcClasspath.add(jar.get().archiveFile.get().asFile)
|
||||
rpcClasspath.addAll(runtimeClasspath.get().files)
|
||||
val classpathString = rpcClasspath.joinToString(File.pathSeparator) { it.absolutePath }
|
||||
systemProperty("org.jetbrains.compose.test.rpc.classpath", classpathString)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
data class Command(val type: Type, val args: List<String>) {
|
||||
enum class Type {
|
||||
ATTACH,
|
||||
FRAME,
|
||||
PREVIEW_CONFIG,
|
||||
PREVIEW_CLASSPATH,
|
||||
PREVIEW_FQ_NAME,
|
||||
FRAME_REQUEST
|
||||
}
|
||||
|
||||
constructor(type: Type, vararg args: String) : this(type, args.toList())
|
||||
|
||||
fun asString() =
|
||||
(sequenceOf(type.name) + args.asSequence()).joinToString(" ")
|
||||
|
||||
companion object {
|
||||
private val typeByName: Map<String, Type> =
|
||||
Type.values().associateBy { it.name }
|
||||
|
||||
fun fromString(s: String): Command? {
|
||||
val wordsIt = s.splitToSequence(" ").iterator()
|
||||
val cmdName = wordsIt.nextOrNull() ?: return null
|
||||
val type = typeByName[cmdName] ?: return null
|
||||
val args = arrayListOf<String>()
|
||||
wordsIt.forEachRemaining {
|
||||
args.add(it)
|
||||
}
|
||||
return Command(type, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
internal object ExitCodes {
|
||||
const val OK = 0
|
||||
const val COULD_NOT_CONNECT_TO_PREVIEW_MANAGER = 1
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
import java.io.PrintStream
|
||||
|
||||
abstract class PreviewLogger {
|
||||
inline operator fun invoke(s: () -> String) {
|
||||
if (isEnabled) {
|
||||
log(s())
|
||||
}
|
||||
}
|
||||
|
||||
inline fun error(msg: () -> String) {
|
||||
invoke { "error: ${msg()}" }
|
||||
}
|
||||
|
||||
abstract val isEnabled: Boolean
|
||||
abstract fun log(s: String)
|
||||
}
|
||||
|
||||
internal class PrintStreamLogger(
|
||||
private val prefix: String,
|
||||
private val printStream: PrintStream = System.out
|
||||
) : PreviewLogger() {
|
||||
override val isEnabled: Boolean = true
|
||||
|
||||
override fun log(s: String) {
|
||||
printStream.print(prefix)
|
||||
printStream.print(":")
|
||||
printStream.println(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.ServerSocket
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
data class PreviewHostConfig(
|
||||
val javaExecutable: String,
|
||||
val hostClasspath: String
|
||||
)
|
||||
|
||||
data class FrameConfig(val width: Int, val height: Int, val scale: Double?) {
|
||||
val scaledWidth: Int get() = scaledValue(width)
|
||||
val scaledHeight: Int get() = scaledValue(height)
|
||||
|
||||
private fun scaledValue(value: Int): Int =
|
||||
if (scale != null) (value.toDouble() * scale).toInt() else value
|
||||
}
|
||||
|
||||
data class FrameRequest(
|
||||
val composableFqName: String,
|
||||
val frameConfig: FrameConfig
|
||||
)
|
||||
|
||||
interface PreviewManager {
|
||||
val gradleCallbackPort: Int
|
||||
fun updateFrameConfig(frameConfig: FrameConfig)
|
||||
fun close()
|
||||
}
|
||||
|
||||
private data class RunningPreview(
|
||||
val connection: RemoteConnection,
|
||||
val process: Process
|
||||
) {
|
||||
val isAlive: Boolean
|
||||
get() = connection.isAlive && process.isAlive
|
||||
}
|
||||
|
||||
class PreviewManagerImpl(private val onNewFrame: (RenderedFrame) -> Unit) : PreviewManager {
|
||||
private val log = PrintStreamLogger("SERVER")
|
||||
private val previewSocket = newServerSocket()
|
||||
private val gradleCallbackSocket = newServerSocket()
|
||||
private val connectionNumber = AtomicLong(0)
|
||||
private val isAlive = AtomicBoolean(true)
|
||||
|
||||
// todo: restart when configuration changes
|
||||
private val previewHostConfig = AtomicReference<PreviewHostConfig>(null)
|
||||
private val previewClasspath = AtomicReference<String>(null)
|
||||
private val previewFqName = AtomicReference<String>(null)
|
||||
private val previewFrameConfig = AtomicReference<FrameConfig>(null)
|
||||
private val frameRequest = AtomicReference<FrameRequest>(null)
|
||||
private val shouldRequestFrame = AtomicBoolean(false)
|
||||
private val runningPreview = AtomicReference<RunningPreview>(null)
|
||||
private val threads = arrayListOf<Thread>()
|
||||
|
||||
private val runPreviewThread = repeatWhileAliveThread("runPreview") {
|
||||
fun startPreviewProcess(config: PreviewHostConfig): Process =
|
||||
ProcessBuilder(
|
||||
config.javaExecutable,
|
||||
"-Djava.awt.headless=true",
|
||||
"-classpath",
|
||||
config.hostClasspath,
|
||||
PREVIEW_HOST_CLASS_NAME,
|
||||
previewSocket.localPort.toString()
|
||||
).apply {
|
||||
// todo: non verbose mode
|
||||
redirectOutput(ProcessBuilder.Redirect.INHERIT)
|
||||
redirectError(ProcessBuilder.Redirect.INHERIT)
|
||||
}.start()
|
||||
|
||||
val runningPreview = runningPreview.get()
|
||||
val previewConfig = previewHostConfig.get()
|
||||
if (previewConfig != null && runningPreview?.isAlive != true) {
|
||||
val process = startPreviewProcess(previewConfig)
|
||||
val connection = tryAcceptConnection(previewSocket, "PREVIEW")
|
||||
connection?.receiveAttach {
|
||||
this.runningPreview.set(RunningPreview(connection, process))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val sendPreviewRequestThread = repeatWhileAliveThread("sendPreviewRequest") {
|
||||
withLivePreviewConnection {
|
||||
val classpath = previewClasspath.get()
|
||||
val fqName = previewFqName.get()
|
||||
val frameConfig = previewFrameConfig.get()
|
||||
|
||||
if (classpath != null && frameConfig != null && fqName != null) {
|
||||
val request = FrameRequest(fqName, frameConfig)
|
||||
if (shouldRequestFrame.get() && frameRequest.get() == null) {
|
||||
if (shouldRequestFrame.compareAndSet(true, false)) {
|
||||
if (frameRequest.compareAndSet(null, request)) {
|
||||
sendPreviewRequest(classpath, request)
|
||||
} else {
|
||||
shouldRequestFrame.compareAndSet(false, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val receivePreviewResponseThread = repeatWhileAliveThread("receivePreviewResponse") {
|
||||
withLivePreviewConnection {
|
||||
receiveFrame { renderedFrame ->
|
||||
frameRequest.get()?.let { request ->
|
||||
frameRequest.compareAndSet(request, null)
|
||||
}
|
||||
onNewFrame(renderedFrame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val gradleCallbackThread = repeatWhileAliveThread("gradleCallback") {
|
||||
tryAcceptConnection(gradleCallbackSocket, "GRADLE_CALLBACK")?.let { connection ->
|
||||
while (isAlive.get() && connection.isAlive) {
|
||||
connection.receiveConfigFromGradle(
|
||||
onPreviewClasspath = { previewClasspath.set(it) },
|
||||
onPreviewHostConfig = { previewHostConfig.set(it) },
|
||||
onPreviewFqName = { previewFqName.set(it) }
|
||||
)
|
||||
shouldRequestFrame.set(true)
|
||||
sendPreviewRequestThread.interrupt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isAlive.compareAndSet(true, false)) return
|
||||
|
||||
closeService("PREVIEW MANAGER") {
|
||||
val runningPreview = runningPreview.getAndSet(null)
|
||||
val previewConnection = runningPreview?.connection
|
||||
val previewProcess = runningPreview?.process
|
||||
threads.forEach { it.interrupt() }
|
||||
|
||||
closeService("PREVIEW HOST CONNECTION") { previewConnection?.close() }
|
||||
closeService("PREVIEW SOCKET") { previewSocket.close() }
|
||||
closeService("GRADLE SOCKET") { gradleCallbackSocket.close() }
|
||||
closeService("THREADS") {
|
||||
for (i in 0..3) {
|
||||
var aliveThreads = 0
|
||||
for (t in threads) {
|
||||
if (t.isAlive) {
|
||||
aliveThreads++
|
||||
t.interrupt()
|
||||
}
|
||||
}
|
||||
if (aliveThreads == 0) break
|
||||
else Thread.sleep(300)
|
||||
}
|
||||
val aliveThreads = threads.filter { it.isAlive }
|
||||
if (aliveThreads.isNotEmpty()) {
|
||||
error("Could not stop threads: ${aliveThreads.joinToString(", ") { it.name }}")
|
||||
}
|
||||
}
|
||||
closeService("PREVIEW HOST PROCESS") {
|
||||
previewProcess?.let { process ->
|
||||
if (!process.waitFor(5, TimeUnit.SECONDS)) {
|
||||
log { "FORCIBLY DESTROYING PREVIEW HOST PROCESS" }
|
||||
// todo: check exit code
|
||||
process.destroyForcibly()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun closeService(name: String, doClose: () -> Unit) {
|
||||
try {
|
||||
log { "CLOSING $name" }
|
||||
val ms = measureTimeMillis {
|
||||
doClose()
|
||||
}
|
||||
log { "CLOSED $name in $ms ms" }
|
||||
} catch (e: Exception) {
|
||||
log.error { "ERROR CLOSING $name: ${e.stackTraceString}" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateFrameConfig(frameConfig: FrameConfig) {
|
||||
previewFrameConfig.set(frameConfig)
|
||||
shouldRequestFrame.set(true)
|
||||
sendPreviewRequestThread.interrupt()
|
||||
}
|
||||
|
||||
override val gradleCallbackPort: Int
|
||||
get() = gradleCallbackSocket.localPort
|
||||
|
||||
private fun tryAcceptConnection(
|
||||
serverSocket: ServerSocket, socketType: String
|
||||
): RemoteConnection? {
|
||||
while (isAlive.get()) {
|
||||
try {
|
||||
val socket = serverSocket.accept()
|
||||
return RemoteConnectionImpl(
|
||||
socket = socket,
|
||||
log = PrintStreamLogger("CONNECTION ($socketType) #${connectionNumber.incrementAndGet()}"),
|
||||
onClose = {
|
||||
// todo
|
||||
}
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
if (e !is SocketTimeoutException) {
|
||||
if (isAlive.get()) {
|
||||
log.error { e.stackTraceToString() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private inline fun withLivePreviewConnection(fn: RemoteConnection.() -> Unit) {
|
||||
val runningPreview = runningPreview.get() ?: return
|
||||
if (runningPreview.isAlive) {
|
||||
runningPreview.connection.fn()
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun repeatWhileAliveThread(
|
||||
name: String,
|
||||
sleepDelayMs: Long = DEFAULT_SLEEP_DELAY_MS,
|
||||
crossinline fn: () -> Unit
|
||||
) = thread(name = name, start = false) {
|
||||
while (isAlive.get()) {
|
||||
try {
|
||||
fn()
|
||||
Thread.sleep(sleepDelayMs)
|
||||
} catch (e: InterruptedException) {
|
||||
continue
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace(System.err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
threads.add(it)
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
import java.io.*
|
||||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
fun getLocalConnectionOrNull(
|
||||
port: Int, logger: PreviewLogger, onClose: () -> Unit
|
||||
): RemoteConnection? =
|
||||
getLocalSocketOrNull(port, trials = 3, trialDelay = 500)?.let { socket ->
|
||||
RemoteConnectionImpl(socket, logger, onClose)
|
||||
}
|
||||
|
||||
abstract class RemoteConnection : AutoCloseable {
|
||||
abstract val isAlive: Boolean
|
||||
abstract fun receiveCommand(onResult: (Command) -> Unit)
|
||||
abstract fun receiveData(onResult: (ByteArray) -> Unit)
|
||||
abstract fun sendCommand(command: Command)
|
||||
abstract fun sendData(data: ByteArray)
|
||||
|
||||
fun receiveUtf8StringData(onResult: (String) -> Unit) {
|
||||
receiveData { bytes ->
|
||||
val string = bytes.toString(Charsets.UTF_8)
|
||||
onResult(string)
|
||||
}
|
||||
}
|
||||
fun sendUtf8StringData(string: String) {
|
||||
sendData(string.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
fun sendCommand(type: Command.Type, vararg args: String) {
|
||||
sendCommand(Command(type, *args))
|
||||
}
|
||||
}
|
||||
|
||||
internal class RemoteConnectionImpl(
|
||||
private val socket: Socket,
|
||||
private val log: PreviewLogger,
|
||||
private val onClose: () -> Unit
|
||||
): RemoteConnection() {
|
||||
init {
|
||||
socket.soTimeout = SOCKET_TIMEOUT_MS
|
||||
}
|
||||
|
||||
private val input = DataInputStream(socket.getInputStream())
|
||||
private val output = DataOutputStream(socket.getOutputStream())
|
||||
private var isConnectionAlive = AtomicBoolean(true)
|
||||
|
||||
override val isAlive: Boolean
|
||||
get() = !socket.isClosed && isConnectionAlive.get()
|
||||
|
||||
private inline fun ifAlive(fn: () -> Unit) {
|
||||
if (isAlive) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (isConnectionAlive.compareAndSet(true, false)) {
|
||||
log { "CLOSING" }
|
||||
socket.close()
|
||||
onClose()
|
||||
log { "CLOSED" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendCommand(command: Command) = ifAlive {
|
||||
val commandStr = command.asString()
|
||||
val data = commandStr.toByteArray()
|
||||
writeData(output, data, maxDataSize = MAX_CMD_SIZE)
|
||||
log { "SENT COMMAND '$commandStr'" }
|
||||
}
|
||||
|
||||
override fun sendData(data: ByteArray) = ifAlive {
|
||||
writeData(output, data, maxDataSize = MAX_BINARY_SIZE)
|
||||
log { "SENT DATA [${data.size}]" }
|
||||
}
|
||||
|
||||
override fun receiveCommand(onResult: (Command) -> Unit) = ifAlive {
|
||||
val line = readData(input, MAX_CMD_SIZE)?.toString(Charsets.UTF_8)
|
||||
if (line != null) {
|
||||
val cmd = Command.fromString(line)
|
||||
if (cmd == null) {
|
||||
log { "GOT UNKNOWN COMMAND '$line'" }
|
||||
} else {
|
||||
log { "GOT COMMAND '$line'" }
|
||||
onResult(cmd)
|
||||
}
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun receiveData(onResult: (ByteArray) -> Unit) = ifAlive {
|
||||
val data = readData(input, MAX_BINARY_SIZE)
|
||||
if (data != null) {
|
||||
log { "GOT [${data.size}]" }
|
||||
onResult(data)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeData(output: DataOutputStream, data: ByteArray, maxDataSize: Int): Boolean {
|
||||
if (!isAlive) return false
|
||||
|
||||
return try {
|
||||
val size = data.size
|
||||
assert(size < maxDataSize) { "Data is too big: $size >= $maxDataSize" }
|
||||
output.writeInt(size)
|
||||
var index = 0
|
||||
val bufSize = minOf(MAX_BUF_SIZE, size)
|
||||
while (index < size) {
|
||||
val len = minOf(bufSize, size - index)
|
||||
output.write(data, index, len)
|
||||
index += len
|
||||
}
|
||||
output.flush()
|
||||
true
|
||||
} catch (e: IOException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun readData(input: DataInputStream, maxDataSize: Int): ByteArray? {
|
||||
while (isAlive) {
|
||||
try {
|
||||
val size = input.readInt()
|
||||
if (size == -1) {
|
||||
break
|
||||
} else {
|
||||
assert(size < maxDataSize) { "Data is too big: $size >= $maxDataSize" }
|
||||
val bytes = ByteArray(size)
|
||||
val bufSize = minOf(size, MAX_BUF_SIZE)
|
||||
var index = 0
|
||||
while (index < size) {
|
||||
val len = minOf(bufSize, size - index)
|
||||
val bytesRead = input.read(bytes, index, len)
|
||||
index += bytesRead
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
if (e !is SocketTimeoutException) break
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
import java.io.File
|
||||
import java.lang.RuntimeException
|
||||
import java.lang.reflect.Method
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URLClassLoader
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
val PREVIEW_HOST_CLASS_NAME: String
|
||||
get() = PreviewHost::class.java.canonicalName
|
||||
|
||||
private class PreviewClassloaderProvider {
|
||||
private var currentClasspath = arrayOf<File>()
|
||||
private var currentSnapshots = hashSetOf<Snapshot>()
|
||||
private var currentClassloader = URLClassLoader(emptyArray())
|
||||
|
||||
// todo: read in memory on Windows
|
||||
fun getClassloader(classpathString: String): ClassLoader {
|
||||
val newClasspath = classpathString.split(File.pathSeparator)
|
||||
.map { File(it) }
|
||||
.toTypedArray()
|
||||
val newSnapshots = newClasspath.mapTo(HashSet()) { Snapshot(it) }
|
||||
if (!currentClasspath.contentEquals(newClasspath) || newSnapshots != currentSnapshots) {
|
||||
currentClasspath = newClasspath
|
||||
currentSnapshots = newSnapshots
|
||||
|
||||
currentClassloader.close()
|
||||
currentClassloader = URLClassLoader(Array(newClasspath.size) { newClasspath[it].toURI().toURL() })
|
||||
}
|
||||
|
||||
return currentClassloader
|
||||
}
|
||||
|
||||
private data class Snapshot(val file: File, val lastModified: Long, val size: Long) {
|
||||
constructor(file: File) : this(file, file.lastModified(), file.length())
|
||||
}
|
||||
}
|
||||
|
||||
internal class PreviewHost(private val log: PreviewLogger, connection: RemoteConnection) {
|
||||
private val previewClasspath = AtomicReference<String>(null)
|
||||
private val previewRequest = AtomicReference<FrameRequest>(null)
|
||||
private val classloaderProvider = PreviewClassloaderProvider()
|
||||
|
||||
init {
|
||||
connection.sendAttach()
|
||||
}
|
||||
|
||||
private val senderThread = thread {
|
||||
while (connection.isAlive) {
|
||||
try {
|
||||
val classpath = previewClasspath.get()
|
||||
val request = previewRequest.get()
|
||||
if (classpath != null && request != null) {
|
||||
if (previewRequest.compareAndSet(request, null)) {
|
||||
val bytes = renderFrame(classpath, request)
|
||||
val config = request.frameConfig
|
||||
val frame = RenderedFrame(bytes, width = config.width, height = config.height)
|
||||
connection.sendFrame(frame)
|
||||
}
|
||||
}
|
||||
Thread.sleep(DEFAULT_SLEEP_DELAY_MS)
|
||||
} catch (e: InterruptedException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val receiverThread = thread {
|
||||
try {
|
||||
while (connection.isAlive) {
|
||||
try {
|
||||
connection.receivePreviewRequest(
|
||||
onPreviewClasspath = {
|
||||
previewClasspath.set(it)
|
||||
senderThread.interrupt()
|
||||
},
|
||||
onFrameRequest = {
|
||||
previewRequest.set(it)
|
||||
senderThread.interrupt()
|
||||
}
|
||||
)
|
||||
} catch (e: SocketTimeoutException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace(System.err)
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
fun join() {
|
||||
senderThread.join()
|
||||
receiverThread.join()
|
||||
}
|
||||
|
||||
private fun renderFrame(
|
||||
classpath: String,
|
||||
request: FrameRequest
|
||||
): ByteArray {
|
||||
val classloader = classloaderProvider.getClassloader(classpath)
|
||||
val previewFacade = classloader.loadClass(PREVIEW_FACADE_CLASS_NAME)
|
||||
val renderArgsClasses = arrayOf(
|
||||
String::class.java,
|
||||
Int::class.java,
|
||||
Int::class.java,
|
||||
java.lang.Double::class.java
|
||||
)
|
||||
val render = try {
|
||||
previewFacade.getMethod("render", *renderArgsClasses)
|
||||
} catch (e: NoSuchMethodException) {
|
||||
val signature =
|
||||
"${previewFacade.canonicalName}#render(${renderArgsClasses.joinToString(", ") { it.simpleName }})"
|
||||
val possibleCandidates = previewFacade.methods.filter { it.name == "render" }
|
||||
throw RuntimeException("Could not find method '$signature'. Possible candidates: \n${possibleCandidates.joinToString("\n") { "* ${it}" }}", e)
|
||||
}
|
||||
val (fqName, frameConfig) = request
|
||||
val scaledWidth = frameConfig.scaledWidth
|
||||
val scaledHeight = frameConfig.scaledHeight
|
||||
val scale = frameConfig.scale
|
||||
log { "RENDERING '$fqName' ${scaledWidth}x$scaledHeight@${scale ?: 1f}" }
|
||||
var bytes: ByteArray
|
||||
val ms = measureTimeMillis {
|
||||
bytes = render.invoke(previewFacade, fqName, scaledWidth, scaledHeight, scale) as ByteArray
|
||||
}
|
||||
log { "RENDERED [${bytes.size}] in $ms ms" }
|
||||
return bytes
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREVIEW_FACADE_CLASS_NAME =
|
||||
"androidx.compose.desktop.ui.tooling.preview.runtime.NonInteractivePreviewFacade"
|
||||
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
val port = args[0].toInt()
|
||||
val logger = PrintStreamLogger("PREVIEW_HOST")
|
||||
val onClose = { exitProcess(ExitCodes.OK) }
|
||||
val connection = getLocalConnectionOrNull(port, logger, onClose = onClose)
|
||||
if (connection != null) {
|
||||
PreviewHost(logger, connection).join()
|
||||
} else {
|
||||
exitProcess(ExitCodes.COULD_NOT_CONNECT_TO_PREVIEW_MANAGER)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
data class RenderedFrame(
|
||||
val bytes: ByteArray,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as RenderedFrame
|
||||
|
||||
if (!bytes.contentEquals(other.bytes)) return false
|
||||
if (width != other.width) return false
|
||||
if (height != other.height) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = bytes.contentHashCode()
|
||||
result = 31 * result + width
|
||||
result = 31 * result + height
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
|
||||
internal fun RemoteConnection.sendAttach() {
|
||||
sendCommand(Command.Type.ATTACH)
|
||||
}
|
||||
|
||||
internal fun RemoteConnection.receiveAttach(fn: () -> Unit) {
|
||||
receiveCommand { (type, _) ->
|
||||
if (type == Command.Type.ATTACH) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun RemoteConnection.sendFrame(frame: RenderedFrame) {
|
||||
sendCommand(Command.Type.FRAME, frame.width.toString(), frame.height.toString())
|
||||
sendData(frame.bytes)
|
||||
}
|
||||
|
||||
internal fun RemoteConnection.receiveFrame(fn: (RenderedFrame) -> Unit) {
|
||||
receiveCommand { (type, args) ->
|
||||
if (type == Command.Type.FRAME) {
|
||||
receiveData { bytes ->
|
||||
val (w, h) = args
|
||||
val frame = RenderedFrame(bytes, width = w.toInt(), height = h.toInt())
|
||||
fn(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun RemoteConnection.sendConfigFromGradle(
|
||||
config: PreviewHostConfig,
|
||||
previewClasspath: String,
|
||||
previewFqName: String
|
||||
) {
|
||||
sendCommand(Command.Type.PREVIEW_CONFIG, URLEncoder.encode(config.javaExecutable, Charsets.UTF_8))
|
||||
sendUtf8StringData(config.hostClasspath)
|
||||
sendCommand(Command.Type.PREVIEW_CLASSPATH)
|
||||
sendUtf8StringData(previewClasspath)
|
||||
sendCommand(Command.Type.PREVIEW_FQ_NAME)
|
||||
sendUtf8StringData(previewFqName)
|
||||
}
|
||||
|
||||
internal fun RemoteConnection.receiveConfigFromGradle(
|
||||
onPreviewClasspath: (String) -> Unit,
|
||||
onPreviewFqName: (String) -> Unit,
|
||||
onPreviewHostConfig: (PreviewHostConfig) -> Unit
|
||||
) {
|
||||
receiveCommand { (type, args) ->
|
||||
when (type) {
|
||||
Command.Type.PREVIEW_CLASSPATH ->
|
||||
receiveUtf8StringData { onPreviewClasspath(it) }
|
||||
Command.Type.PREVIEW_FQ_NAME ->
|
||||
receiveUtf8StringData { onPreviewFqName(it) }
|
||||
Command.Type.PREVIEW_CONFIG -> {
|
||||
val javaExecutable = URLDecoder.decode(args[0], Charsets.UTF_8)
|
||||
receiveUtf8StringData { hostClasspath ->
|
||||
val config = PreviewHostConfig(javaExecutable = javaExecutable, hostClasspath = hostClasspath)
|
||||
onPreviewHostConfig(config)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// todo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun RemoteConnection.sendPreviewRequest(
|
||||
previewClasspath: String,
|
||||
request: FrameRequest
|
||||
) {
|
||||
sendCommand(Command.Type.PREVIEW_CLASSPATH)
|
||||
sendData(previewClasspath.toByteArray(Charsets.UTF_8))
|
||||
val (fqName, frameConfig) = request
|
||||
val (w, h, scale) = frameConfig
|
||||
val args = arrayListOf(fqName, w.toString(), h.toString())
|
||||
if (scale != null) {
|
||||
val scaleLong = java.lang.Double.doubleToRawLongBits(scale)
|
||||
args.add(scaleLong.toString())
|
||||
}
|
||||
sendCommand(Command.Type.FRAME_REQUEST, *args.toTypedArray())
|
||||
}
|
||||
|
||||
internal fun RemoteConnection.receivePreviewRequest(
|
||||
onPreviewClasspath: (String) -> Unit,
|
||||
onFrameRequest: (FrameRequest) -> Unit
|
||||
) {
|
||||
receiveCommand { (type, args) ->
|
||||
when (type) {
|
||||
Command.Type.PREVIEW_CLASSPATH -> {
|
||||
receiveUtf8StringData { onPreviewClasspath(it) }
|
||||
}
|
||||
Command.Type.FRAME_REQUEST -> {
|
||||
val fqName = args.getOrNull(0)
|
||||
val w = args.getOrNull(1)?.toIntOrNull()
|
||||
val h = args.getOrNull(2)?.toIntOrNull()
|
||||
val scale = args.getOrNull(3)?.toLongOrNull()?.let { java.lang.Double.longBitsToDouble(it) }
|
||||
if (
|
||||
fqName != null && fqName.isNotEmpty()
|
||||
&& w != null && w > 0
|
||||
&& h != null && h > 0
|
||||
) {
|
||||
onFrameRequest(FrameRequest(fqName, FrameConfig(width = w, height = h, scale = scale)))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// todo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
internal const val SOCKET_TIMEOUT_MS = 1000
|
||||
internal const val DEFAULT_SLEEP_DELAY_MS = 1000L
|
||||
internal const val MAX_CMD_SIZE = 8 * 1024
|
||||
// 100 Mb should be enough even for 8K screenshots
|
||||
internal const val MAX_BINARY_SIZE = 100 * 1024 * 1024
|
||||
internal const val MAX_BUF_SIZE = 8 * 1024
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.PrintStream
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
|
||||
internal fun getLocalSocketOrNull(
|
||||
port: Int,
|
||||
trials: Int,
|
||||
trialDelay: Long,
|
||||
): Socket? {
|
||||
for (i in 0..trials) {
|
||||
try {
|
||||
return Socket(localhost, port)
|
||||
} catch (e: IOException) {
|
||||
Thread.sleep(trialDelay)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
internal val localhost: InetAddress
|
||||
get() = InetAddress.getLoopbackAddress()
|
||||
|
||||
internal fun newServerSocket() =
|
||||
ServerSocket(0, 0, localhost).apply {
|
||||
reuseAddress = true
|
||||
soTimeout = SOCKET_TIMEOUT_MS
|
||||
}
|
||||
|
||||
internal fun <T> Iterator<T>.nextOrNull(): T? =
|
||||
if (hasNext()) next() else null
|
||||
|
||||
internal val Throwable.stackTraceString: String
|
||||
get() {
|
||||
val output = ByteArrayOutputStream()
|
||||
PrintStream(output).use {
|
||||
printStackTrace(it)
|
||||
}
|
||||
return output.toString(Charsets.UTF_8)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils.*
|
||||
import org.junit.jupiter.api.*
|
||||
import java.net.ServerSocket
|
||||
import java.util.concurrent.atomic.*
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
internal class PreviewHostTest {
|
||||
private lateinit var serverSocket: ServerSocket
|
||||
|
||||
@BeforeEach
|
||||
internal fun setUp() {
|
||||
serverSocket = ServerSocket(0, 0, localhost)
|
||||
serverSocket.soTimeout = 10.secondsAsMillis
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
internal fun tearDown() {
|
||||
serverSocket.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectNormallyAndStop() {
|
||||
withPreviewHostConnection { connection ->
|
||||
connection.receiveCommand { command ->
|
||||
check(command.type == Command.Type.ATTACH) {
|
||||
"First received command is not ${Command.Type.ATTACH}: ${command.asString()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun withPreviewHostConnection(doWithConnection: (RemoteConnection) -> Unit) {
|
||||
val isServerConnectionClosed = AtomicBoolean(false)
|
||||
val serverThread = thread {
|
||||
val socket = serverSocket.accept()
|
||||
val logger = TestLogger()
|
||||
val connection = RemoteConnectionImpl(socket, logger, onClose = {
|
||||
isServerConnectionClosed.set(true)
|
||||
})
|
||||
doWithConnection(connection)
|
||||
connection.close()
|
||||
}
|
||||
val serverThreadFailure = AtomicReference<Throwable>(null)
|
||||
serverThread.setUncaughtExceptionHandler { t, e ->
|
||||
serverThreadFailure.set(e)
|
||||
}
|
||||
|
||||
val previewHostProcess = TestPreviewProcess(serverSocket.localPort)
|
||||
previewHostProcess.start()
|
||||
|
||||
serverThread.join(10L.secondsAsMillis)
|
||||
val serverFailure = serverThreadFailure.get()
|
||||
check(serverFailure == null) { "Unexpected server failure: $serverFailure" }
|
||||
check(!serverThread.isAlive) { "Server thread should not be alive at this point" }
|
||||
check(isServerConnectionClosed.get()) { "Server connection was not closed" }
|
||||
|
||||
previewHostProcess.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils
|
||||
|
||||
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PreviewLogger
|
||||
|
||||
internal class TestLogger : PreviewLogger() {
|
||||
private val myMessages = arrayListOf<String>()
|
||||
val messages: List<String>
|
||||
get() = myMessages
|
||||
|
||||
override val isEnabled: Boolean
|
||||
get() = true
|
||||
|
||||
override fun log(s: String) {
|
||||
myMessages.add(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils
|
||||
|
||||
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PREVIEW_HOST_CLASS_NAME
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class TestPreviewProcess(private val port: Int) {
|
||||
private var myProcess: Process? = null
|
||||
|
||||
fun start() {
|
||||
if (myProcess != null) error("Process was started already")
|
||||
|
||||
myProcess = runJava(
|
||||
headless = true,
|
||||
classpath = previewTestClaspath,
|
||||
args = listOf(PREVIEW_HOST_CLASS_NAME, port.toString())
|
||||
).start()
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
val process = myProcess
|
||||
check(process != null) { "Process was not started" }
|
||||
|
||||
process.waitFor(10, TimeUnit.SECONDS)
|
||||
if (process.isAlive) {
|
||||
val jstackOutput = runJStackAndGetOutput(process.pid())
|
||||
val message = buildString {
|
||||
appendLine("Preview host process did not stop:")
|
||||
jstackOutput.splitToSequence("\n").forEach {
|
||||
appendLine("> $it")
|
||||
}
|
||||
}
|
||||
process.destroyForcibly()
|
||||
error(message)
|
||||
}
|
||||
val exitCode = process.exitValue()
|
||||
check(exitCode == 0) { "Non-zero exit code: $exitCode" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils
|
||||
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal fun runJava(
|
||||
headless: Boolean = true,
|
||||
debugPort: Int? = null,
|
||||
classpath: String = "",
|
||||
args: List<String> = emptyList()
|
||||
): ProcessBuilder {
|
||||
val javaExec = javaToolPath("java")
|
||||
val cmd = arrayListOf(
|
||||
javaExec,
|
||||
"-classpath",
|
||||
classpath
|
||||
)
|
||||
if (headless) {
|
||||
cmd.add("-Djava.awt.headless=true")
|
||||
}
|
||||
if (debugPort != null) {
|
||||
cmd.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:$debugPort")
|
||||
}
|
||||
cmd.addAll(args)
|
||||
println("Starting process: [${cmd.joinToString(",") { "\n $it" } }\n]")
|
||||
return ProcessBuilder(cmd).apply {
|
||||
redirectError(ProcessBuilder.Redirect.INHERIT)
|
||||
redirectOutput(ProcessBuilder.Redirect.INHERIT)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun runJStackAndGetOutput(
|
||||
pid: Long
|
||||
): String {
|
||||
val jstack = javaToolPath("jstack")
|
||||
val stdoutFile = File.createTempFile("jstack-stdout", ".txt").apply { deleteOnExit() }
|
||||
val stderrFile = File.createTempFile("jstack-stderr", ".txt").apply { deleteOnExit() }
|
||||
|
||||
try {
|
||||
val process = ProcessBuilder(jstack, pid.toString()).apply {
|
||||
redirectOutput(stdoutFile)
|
||||
redirectError(stderrFile)
|
||||
}.start()
|
||||
process.waitFor(10, TimeUnit.SECONDS)
|
||||
if (process.isAlive) {
|
||||
process.destroyForcibly()
|
||||
error("jstack did not finish")
|
||||
}
|
||||
val exitCode = process.exitValue()
|
||||
check(exitCode == 0) {
|
||||
buildString {
|
||||
appendLine("jstack finished with error: $exitCode")
|
||||
appendLine(" output:")
|
||||
stdoutFile.readLines().forEach {
|
||||
appendLine(" >")
|
||||
}
|
||||
appendLine(" err:")
|
||||
stderrFile.readLines().forEach {
|
||||
appendLine(" >")
|
||||
}
|
||||
}
|
||||
" $exitCode\n${stderrFile.readText()}"
|
||||
}
|
||||
return stdoutFile.readText()
|
||||
} finally {
|
||||
stdoutFile.delete()
|
||||
stderrFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun javaToolPath(toolName: String): String {
|
||||
val javaHome = File(systemProperty("java.home"))
|
||||
val toolExecutableName = if (isWindows) "$toolName.exe" else toolName
|
||||
val executable = javaHome.resolve("bin/$toolExecutableName")
|
||||
check(executable.isFile) { "Could not find tool '$toolName' at specified path: $executable" }
|
||||
return executable.absolutePath
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils
|
||||
|
||||
internal fun systemProperty(name: String): String =
|
||||
System.getProperty(name) ?: error("System property is not found: '$name'")
|
||||
|
||||
internal val isWindows =
|
||||
systemProperty("os.name").startsWith("windows", ignoreCase = true)
|
||||
|
||||
internal val previewTestClaspath: String
|
||||
get() = systemProperty("org.jetbrains.compose.test.rpc.classpath")
|
||||
|
||||
internal val Int.secondsAsMillis: Int
|
||||
get() = this * 1000
|
||||
|
||||
internal val Long.secondsAsMillis: Long
|
||||
get() = this * 1000
|
||||
@@ -5,4 +5,4 @@ pluginManagement {
|
||||
}
|
||||
|
||||
include(":compose")
|
||||
include(":compose-preview-runtime")
|
||||
include(":preview-rpc")
|
||||
|
||||
@@ -16,6 +16,10 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.jetbrains.compose:preview-rpc")
|
||||
}
|
||||
|
||||
intellij {
|
||||
pluginName = "Compose Multiplatform IDE Support"
|
||||
type = properties("platform.type")
|
||||
|
||||
@@ -3,7 +3,7 @@ import org.jetbrains.compose.compose
|
||||
plugins {
|
||||
// __KOTLIN_COMPOSE_VERSION__
|
||||
kotlin("jvm") version "1.5.10"
|
||||
id("org.jetbrains.compose") version "0.4.0-idea-preview-build57"
|
||||
id("org.jetbrains.compose") version "0.0.0-non-interactive-preview-build89-4"
|
||||
}
|
||||
|
||||
repositories {
|
||||
@@ -12,7 +12,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.4")
|
||||
implementation(compose.uiTooling)
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
kotlin.code.style=official
|
||||
kotlin.code.style=official
|
||||
#org.gradle.unsafe.configuration-cache=true
|
||||
#org.gradle.unsafe.configuration-cache-problems=warn
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.6-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.desktop.preview.Preview
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
|
||||
@Preview
|
||||
fun examplePreview() = @Composable {
|
||||
@Composable
|
||||
fun ExamplePreview() {
|
||||
var text by remember { mutableStateOf("Hello, World!") }
|
||||
|
||||
Button(onClick = {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
includeBuild("../gradle-plugins") {
|
||||
name = "compose-gradle-components"
|
||||
}
|
||||
@@ -20,12 +20,9 @@ import com.intellij.codeInspection.reference.EntryPoint
|
||||
import com.intellij.codeInspection.reference.RefElement
|
||||
import com.intellij.configurationStore.deserializeInto
|
||||
import com.intellij.configurationStore.serializeObjectInto
|
||||
import com.intellij.openapi.util.InvalidDataException
|
||||
import com.intellij.openapi.util.WriteExternalException
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiMethod
|
||||
import org.jdom.Element
|
||||
import org.jetbrains.annotations.Nls
|
||||
|
||||
/**
|
||||
* [EntryPoint] implementation to mark `@Preview` functions as entry points and avoid them being flagged as unused.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ide.preview
|
||||
|
||||
import java.awt.Color
|
||||
import java.awt.Dimension
|
||||
import java.awt.Graphics
|
||||
import java.awt.image.BufferedImage
|
||||
import javax.swing.JPanel
|
||||
|
||||
internal class PreviewPanel : JPanel() {
|
||||
private var image: BufferedImage? = null
|
||||
private var imageDimension: Dimension? = null
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
super.paintComponent(g)
|
||||
|
||||
synchronized(this) {
|
||||
image?.let { image ->
|
||||
val w = imageDimension!!.width
|
||||
val h = imageDimension!!.height
|
||||
g.color = Color.WHITE
|
||||
g.fillRect(0, 0, w, h)
|
||||
g.drawImage(image, 0, 0, w, h, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun previewImage(image: BufferedImage, imageDimension: Dimension) {
|
||||
synchronized(this) {
|
||||
this.image = image
|
||||
this.imageDimension = imageDimension
|
||||
}
|
||||
|
||||
repaint()
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@
|
||||
package org.jetbrains.compose.desktop.ide.preview
|
||||
|
||||
import com.intellij.execution.actions.ConfigurationContext
|
||||
import com.intellij.execution.actions.ConfigurationFromContext
|
||||
import com.intellij.execution.actions.LazyRunConfigurationProducer
|
||||
import com.intellij.execution.configurations.ConfigurationFactory
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil
|
||||
import com.intellij.openapi.util.Ref
|
||||
import com.intellij.psi.PsiElement
|
||||
@@ -47,13 +49,13 @@ class PreviewRunConfigurationProducer : LazyRunConfigurationProducer<GradleRunCo
|
||||
context: ConfigurationContext
|
||||
): Boolean {
|
||||
val composeFunction = context.containingComposePreviewFunction() ?: return false
|
||||
|
||||
return configuration.run {
|
||||
name == composeFunction.name!!
|
||||
&& settings.externalProjectPath == context.modulePath()
|
||||
&& settings.scriptParameters.contains(
|
||||
previewTargetGradleArg(composeFunction.composePreviewFunctionFqn())
|
||||
)
|
||||
name == runConfigurationNameFor(composeFunction)
|
||||
&& settings.externalProjectPath == context.modulePath()
|
||||
&& settings.taskNames.singleOrNull() == configureDesktopPreviewTaskName
|
||||
&& settings.scriptParameters.split(" ").containsAll(
|
||||
runConfigurationScriptParameters(composeFunction.composePreviewFunctionFqn(), context.port)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,21 +65,33 @@ class PreviewRunConfigurationProducer : LazyRunConfigurationProducer<GradleRunCo
|
||||
sourceElement: Ref<PsiElement>
|
||||
): Boolean {
|
||||
val composeFunction = context.containingComposePreviewFunction() ?: return false
|
||||
|
||||
// todo: temporary configuration?
|
||||
configuration.apply {
|
||||
name = composeFunction.name!!
|
||||
settings.taskNames.add("runComposeDesktopPreview")
|
||||
name = runConfigurationNameFor(composeFunction)
|
||||
settings.taskNames.add(configureDesktopPreviewTaskName)
|
||||
settings.externalProjectPath = ExternalSystemApiUtil.getExternalProjectPath(context.location?.module)
|
||||
settings.scriptParameters = listOf(
|
||||
previewTargetGradleArg(composeFunction.composePreviewFunctionFqn())
|
||||
).joinToString(" ")
|
||||
settings.scriptParameters =
|
||||
runConfigurationScriptParameters(composeFunction.composePreviewFunctionFqn(), context.port)
|
||||
.joinToString(" ")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun previewTargetGradleArg(target: String): String =
|
||||
"-Pcompose.desktop.preview.target=$target"
|
||||
private val configureDesktopPreviewTaskName = "configureDesktopPreview"
|
||||
|
||||
private fun runConfigurationNameFor(function: KtNamedFunction): String =
|
||||
"Compose Preview: ${function.name!!}"
|
||||
|
||||
private fun runConfigurationScriptParameters(target: String, idePort: Int): List<String> =
|
||||
listOf(
|
||||
"-Pcompose.desktop.preview.target=$target",
|
||||
"-Pcompose.desktop.preview.ide.port=${idePort}"
|
||||
)
|
||||
|
||||
private val ConfigurationContext.port: Int
|
||||
get() = project.service<PreviewStateService>().gradleCallbackPort
|
||||
|
||||
private fun KtNamedFunction.composePreviewFunctionFqn() = "${getClassName()}.${name}"
|
||||
|
||||
|
||||
@@ -36,11 +36,10 @@ class PreviewRunLineMarkerContributor : RunLineMarkerContributor() {
|
||||
|
||||
val parent = element.parent
|
||||
return when {
|
||||
parent is KtNamedFunction && parent.isValidComposePreview() ->
|
||||
Info(
|
||||
PreviewIcons.COMPOSE,
|
||||
arrayOf(ExecutorAction.getActions(0).first())
|
||||
) { PreviewMessages.runPreview(parent.name!!) }
|
||||
parent is KtNamedFunction && parent.isValidComposePreview() -> {
|
||||
val actions = arrayOf(ExecutorAction.getActions(0).first())
|
||||
Info(PreviewIcons.COMPOSE, actions) { PreviewMessages.runPreview(parent.name!!) }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ide.preview
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.FrameConfig
|
||||
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PreviewManager
|
||||
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PreviewManagerImpl
|
||||
import java.awt.Dimension
|
||||
import java.io.ByteArrayInputStream
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.event.AncestorEvent
|
||||
import javax.swing.event.AncestorListener
|
||||
|
||||
@Service
|
||||
class PreviewStateService : Disposable {
|
||||
private var myPanel: PreviewPanel? = null
|
||||
private val previewManager: PreviewManager = PreviewManagerImpl { frame ->
|
||||
ByteArrayInputStream(frame.bytes).use { input ->
|
||||
val image = ImageIO.read(input)
|
||||
myPanel?.previewImage(image, Dimension(frame.width, frame.height))
|
||||
}
|
||||
}
|
||||
val gradleCallbackPort: Int
|
||||
get() = previewManager.gradleCallbackPort
|
||||
|
||||
private val myListener = object : AncestorListener {
|
||||
private fun updateFrameSize(c: JComponent) {
|
||||
val frameConfig = FrameConfig(
|
||||
width = c.width,
|
||||
height = c.height,
|
||||
scale = null
|
||||
)
|
||||
previewManager.updateFrameConfig(frameConfig)
|
||||
}
|
||||
|
||||
override fun ancestorAdded(event: AncestorEvent) {
|
||||
updateFrameSize(event.component)
|
||||
}
|
||||
|
||||
override fun ancestorRemoved(event: AncestorEvent) {
|
||||
}
|
||||
|
||||
override fun ancestorMoved(event: AncestorEvent) {
|
||||
updateFrameSize(event.component)
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
myPanel?.removeAncestorListener(myListener)
|
||||
previewManager.close()
|
||||
}
|
||||
|
||||
internal fun registerPreviewPanel(panel: PreviewPanel) {
|
||||
myPanel = panel
|
||||
panel.addAncestorListener(myListener)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
package org.jetbrains.compose.desktop.ide.preview
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.wm.ToolWindow
|
||||
import com.intellij.openapi.wm.ToolWindowFactory
|
||||
|
||||
class PreviewToolWindow : ToolWindowFactory, DumbAware {
|
||||
override fun isApplicable(project: Project): Boolean {
|
||||
// todo: filter only Compose projects
|
||||
return true
|
||||
}
|
||||
|
||||
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
|
||||
toolWindow.contentManager.let { content ->
|
||||
val panel = PreviewPanel()
|
||||
content.addContent(content.factory.createContent(panel, null, false))
|
||||
project.service<PreviewStateService>().registerPreviewPanel(panel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import org.jetbrains.kotlin.resolve.BindingContext
|
||||
import org.jetbrains.kotlin.resolve.descriptorUtil.fqNameSafe
|
||||
import org.jetbrains.kotlin.resolve.lazy.BodyResolveMode
|
||||
|
||||
internal const val DESKTOP_PREVIEW_ANNOTATION_FQN = "androidx.compose.ui.tooling.desktop.preview.Preview"
|
||||
internal const val DESKTOP_PREVIEW_ANNOTATION_FQN = "androidx.compose.desktop.ui.tooling.preview.Preview"
|
||||
|
||||
/**
|
||||
* Utils based on functions from AOSP, taken from
|
||||
@@ -49,6 +49,8 @@ internal fun KtNamedFunction.isValidComposePreview() =
|
||||
*
|
||||
*/
|
||||
internal fun KtNamedFunction.isValidPreviewLocation(): Boolean {
|
||||
if (valueParameters.size > 0) return false
|
||||
|
||||
if (isTopLevel) return true
|
||||
|
||||
if (parentOfType<KtNamedFunction>() == null) {
|
||||
|
||||
@@ -34,5 +34,9 @@
|
||||
<runConfigurationProducer
|
||||
implementation="org.jetbrains.compose.web.ide.run.WebRunConfigurationProducer"/>
|
||||
|
||||
<toolWindow
|
||||
factoryClass="org.jetbrains.compose.desktop.ide.preview.PreviewToolWindow"
|
||||
id="Desktop Preview" doNotActivateOnStart="true"
|
||||
anchor="right" />
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
||||
Reference in New Issue
Block a user