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 |
||||
|
||||
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) |
||||
} |
||||
} |
||||
} |
@ -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 |
||||
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 |
||||
|
@ -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