Alexey Tsvetkov
4 years ago
committed by
GitHub
41 changed files with 1465 additions and 149 deletions
@ -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) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -1,25 +1,18 @@ |
|||||||
package org.jetbrains.compose.desktop.preview.internal |
package org.jetbrains.compose.desktop.preview.internal |
||||||
|
|
||||||
import org.gradle.api.Project |
import org.gradle.api.Project |
||||||
import org.jetbrains.compose.composeVersion |
|
||||||
import org.jetbrains.compose.desktop.application.dsl.Application |
import org.jetbrains.compose.desktop.application.dsl.Application |
||||||
import org.jetbrains.compose.desktop.application.internal.javaHomeOrDefault |
import org.jetbrains.compose.desktop.application.internal.javaHomeOrDefault |
||||||
import org.jetbrains.compose.desktop.application.internal.provider |
import org.jetbrains.compose.desktop.application.internal.provider |
||||||
import org.jetbrains.compose.desktop.preview.tasks.AbstractRunComposePreviewTask |
import org.jetbrains.compose.desktop.preview.tasks.AbstractConfigureDesktopPreviewTask |
||||||
|
|
||||||
internal const val PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION = "composeDesktopPreviewRuntimeClasspath" |
|
||||||
private val COMPOSE_PREVIEW_RUNTIME_DEPENDENCY = "org.jetbrains.compose:compose-preview-runtime-desktop:$composeVersion" |
|
||||||
|
|
||||||
fun Project.initializePreview() { |
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 -> |
app._configurationSource?.let { configSource -> |
||||||
dependsOn(configSource.jarTaskName) |
dependsOn(configSource.jarTaskName) |
||||||
classpath = configSource.runtimeClasspath(project) |
previewClasspath = configSource.runtimeClasspath(project) |
||||||
javaHome.set(provider { app.javaHomeOrDefault() }) |
javaHome.set(provider { app.javaHomeOrDefault() }) |
||||||
jvmArgs.set(provider { app.jvmArgs }) |
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) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -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 |
@ -1,2 +1,4 @@ |
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 |
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 |
distributionBase=GRADLE_USER_HOME |
||||||
distributionPath=wrapper/dists |
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 |
zipStoreBase=GRADLE_USER_HOME |
||||||
zipStorePath=wrapper/dists |
zipStorePath=wrapper/dists |
||||||
|
@ -0,0 +1,3 @@ |
|||||||
|
includeBuild("../gradle-plugins") { |
||||||
|
name = "compose-gradle-components" |
||||||
|
} |
@ -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() |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue