diff --git a/changelog.d/68.fixed.md b/changelog.d/68.fixed.md new file mode 100644 index 00000000..4ee1a140 --- /dev/null +++ b/changelog.d/68.fixed.md @@ -0,0 +1 @@ +The plugin now manages binaries in the background to prevent UI freeze. \ No newline at end of file diff --git a/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordBinaryManager.kt b/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordBinaryManager.kt index 07e32532..473805ba 100644 --- a/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordBinaryManager.kt +++ b/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordBinaryManager.kt @@ -2,9 +2,13 @@ package com.metalbear.mirrord import com.intellij.execution.wsl.WSLDistribution import com.intellij.notification.NotificationType +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity import com.intellij.openapi.util.SystemInfo import com.intellij.util.system.CpuArch import java.net.URI @@ -14,10 +18,11 @@ import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.charset.Charset import java.nio.file.Files -import java.nio.file.Path +import java.nio.file.StandardCopyOption import java.time.Duration -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit +import java.util.* +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.io.path.name private const val CLI_BINARY = "mirrord" private const val VERSION_ENDPOINT = "https://version.mirrord.dev/v1/version" @@ -26,115 +31,132 @@ private const val DOWNLOAD_ENDPOINT = "https://github.com/metalbear-co/mirrord/r /** * For dynamically fetching and storing mirrord binary. */ -class MirrordBinaryManager(private val service: MirrordProjectService) { - class MirrordBinary(val command: String) { - val version: String - - init { - val child = Runtime.getRuntime().exec(arrayOf(command, "--version")) - - val result = child.waitFor() - if (result != 0) { - MirrordLogger.logger.debug("`mirrord --version` failed with code $result") - throw RuntimeException("failed to get mirrord version") - } - - version = child.inputReader().readLine().split(' ')[1].trim() - } - } +@Service(Service.Level.APP) +class MirrordBinaryManager { + @Volatile + private var latestSupportedVersion: String? = null /** - * @return executable found by `which mirrord` + * Schedules the update task at project startup. */ - private fun findBinaryInPath(wslDistribution: WSLDistribution?): MirrordBinary { - return if (wslDistribution == null) { - val child = Runtime.getRuntime().exec(arrayOf("which", "mirrord")) - val result = child.waitFor() - if (result != 0) { - throw RuntimeException("`which` failed with code $result") - } - MirrordBinary(child.inputReader().readLine().trim()) - } else { - val output = wslDistribution.executeOnWsl(1000, "which", "mirrord") - if (output.exitCode != 0) { - throw RuntimeException("`which` failed with code ${output.exitCode}") - } - MirrordBinary(output.stdoutLines.first().trim()) + class DownloadInitializer : StartupActivity.Background { + override fun runActivity(project: Project) { + UpdateTask(project, null, null, false).queue() } } /** - * @return the local installation of mirrord + * Runs version check and binary download in the background. + * + * @param product for example "idea", "goland", null if unknown + * @param wslDistribution null if not applicable or unknown + * @param checkInPath whether the task should attempt to find binary installation in PATH. + * Should be false if wslDistribution is unknown */ - private fun getLocalBinary(requiredVersion: String?, wslDistribution: WSLDistribution?): MirrordBinary? { - try { - val foundInPath = this.findBinaryInPath(wslDistribution) - if (requiredVersion == null || requiredVersion == foundInPath.version) { - return foundInPath - } - } catch (e: Exception) { - MirrordLogger.logger.debug("failed to find mirrord in path", e) + private class UpdateTask( + private val project: Project, + private val product: String?, + private val wslDistribution: WSLDistribution?, + private val checkInPath: Boolean + ) : Task.Backgroundable(project, "mirrord", true), DumbAware { + companion object State { + /** + * Only one download may be happening at the same time. + */ + val downloadInProgress = AtomicBoolean(false) } - try { - MirrordPathManager.getBinary(CLI_BINARY, true)?.let { - val binary = MirrordBinary(it) - if (requiredVersion == null || requiredVersion == binary.version) { - return binary - } + /** + * Binary version being downloaded by this task. + */ + private var downloadingVersion: String? = null + + override fun run(indicator: ProgressIndicator) { + val manager = service() + + val version = manager.fetchLatestSupportedVersion(product, indicator) + manager.latestSupportedVersion = version + + val local = if (checkInPath) { + manager.getLocalBinary(downloadingVersion, wslDistribution) + } else { + manager.findBinaryInStorage(downloadingVersion) } - } catch (e: Exception) { - MirrordLogger.logger.debug("failed to find mirrord in plugin storage", e) + if (local != null) { + return + } + + if (downloadInProgress.compareAndExchange(false, true)) { + return + } + + downloadingVersion = version + manager.updateBinary(indicator) } - return null - } + override fun onThrowable(error: Throwable) { + MirrordLogger.logger.debug("binary update task failed", error) - private fun getLatestSupportedVersion(product: String, timeout: Duration): String { - val environment = CompletableFuture() - val versionCheckTask = object : Task.Backgroundable(service.project, "mirrord", true) { - override fun run(indicator: ProgressIndicator) { - indicator.text = "mirrord is checking the latest supported version..." - - val testing = System.getenv("CI_BUILD_PLUGIN") == "true" || - System.getenv("PLUGIN_TESTING_ENVIRONMENT") == "true" - val version = if (testing) { - "test" - } else { - VERSION ?: "unknown" - } + project.service() + .notifier + .notifyRichError("failed to update the mirrord binary: ${error.message}") + } - val url = StringBuilder(VERSION_ENDPOINT) - .append("?source=3") - .append("&version=") - .append(URLEncoder.encode(version, Charset.defaultCharset())) - .append("&platform=") - .append(URLEncoder.encode(SystemInfo.OS_NAME, Charset.defaultCharset())) - .toString() - - val client = HttpClient.newHttpClient() - val request = HttpRequest - .newBuilder(URI(url)) - .header("user-agent", product) - .timeout(timeout) - .GET() - .build() - val response = client.send(request, HttpResponse.BodyHandlers.ofString()) - - environment.complete(response.body()) + override fun onFinished() { + if (downloadingVersion != null) { + downloadInProgress.set(false) } + } - override fun onThrowable(error: Throwable) { - MirrordLogger.logger.debug(error) + override fun onSuccess() { + downloadingVersion?.let { + project + .service() + .notifier + .notifySimple( + "downloaded mirrord binary version $downloadingVersion", + NotificationType.INFORMATION + ) } } + } + + private fun fetchLatestSupportedVersion(product: String?, indicator: ProgressIndicator): String { + val pluginVersion = if ( + System.getenv("CI_BUILD_PLUGIN") == "true" || + System.getenv("PLUGIN_TESTING_ENVIRONMENT") == "true" + ) { + "test" + } else { + VERSION ?: "unknown" + } + + indicator.text = "mirrord is checking the latest supported binary version..." - ProgressManager.getInstance().run(versionCheckTask) + val url = StringBuilder(VERSION_ENDPOINT) + .append("?source=3") + .append("&version=") + .append(URLEncoder.encode(pluginVersion, Charset.defaultCharset())) + .append("&platform=") + .append(URLEncoder.encode(SystemInfo.OS_NAME, Charset.defaultCharset())) + .toString() - return environment.get(timeout.seconds, TimeUnit.SECONDS) + val client = HttpClient.newHttpClient() + val builder = HttpRequest + .newBuilder(URI(url)) + .timeout(Duration.ofSeconds(10L)) + .GET() + + product?.let { builder.header("user-agent", it) } + + val response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + + return response.body() } - private fun downloadBinary(destination: Path, version: String) { + private fun updateBinary(indicator: ProgressIndicator) { + val version = latestSupportedVersion ?: return + val url = if (SystemInfo.isMac) { "$DOWNLOAD_ENDPOINT/$version/mirrord_mac_universal" } else if (SystemInfo.isLinux || SystemInfo.isWindows) { @@ -149,95 +171,139 @@ class MirrordBinaryManager(private val service: MirrordProjectService) { throw RuntimeException("Unsupported platform: " + SystemInfo.OS_NAME) } - val environment = CompletableFuture() - val versionCheckTask = object : Task.Backgroundable(service.project, "mirrord", true) { - override fun run(indicator: ProgressIndicator) { - indicator.text = "mirrord is downloading version $version..." - indicator.fraction = 0.0 - - val connection = URI(url).toURL().openConnection() - connection.connect() - val size = connection.contentLength - val stream = connection.getInputStream() - - val bytes = ByteArray(size) - var bytesRead = 0 - while (bytesRead < size) { - val toRead = minOf(4096, size - bytesRead) - val readNow = stream.read(bytes, bytesRead, toRead) - if (readNow == -1) { - break - } - bytesRead += readNow - indicator.fraction = bytesRead.toDouble() / size.toDouble() - } + indicator.text = "mirrord is downloading binary version $version..." + indicator.fraction = 0.0 + + val connection = URI(url).toURL().openConnection() + connection.connect() + val size = connection.contentLength + val stream = connection.getInputStream() + + val bytes = ByteArray(size) + var bytesRead = 0 + while (bytesRead < size) { + indicator.checkCanceled() + val toRead = minOf(4096, size - bytesRead) + val readNow = stream.read(bytes, bytesRead, toRead) + if (readNow == -1) { + break + } + bytesRead += readNow + indicator.fraction = bytesRead.toDouble() / size.toDouble() + } - stream.close() - environment.complete(bytes) + stream.close() + + val destination = MirrordPathManager.getPath(CLI_BINARY, true) + Files.createDirectories(destination.parent) + + val tmpDestination = destination.resolveSibling(destination.name + UUID.randomUUID().toString()) + + Files.write(tmpDestination, bytes) + destination.toFile().setExecutable(true) + Files.move(tmpDestination, destination, StandardCopyOption.REPLACE_EXISTING) + } + + private class MirrordBinary(val command: String) { + val version: String + + init { + val child = Runtime.getRuntime().exec(arrayOf(command, "--version")) + + val result = child.waitFor() + if (result != 0) { + MirrordLogger.logger.debug("`mirrord --version` failed with code $result") + throw RuntimeException("failed to get mirrord version") + } + + version = child.inputReader().readLine().split(' ')[1].trim() + } + } + + /** + * @return executable found with `which mirrord` + */ + private fun findBinaryInPath(requiredVersion: String?, wslDistribution: WSLDistribution?): MirrordBinary? { + try { + val output = if (wslDistribution == null) { + val child = Runtime.getRuntime().exec(arrayOf("which", "mirrord")) + val result = child.waitFor() + if (result != 0) { + throw RuntimeException("`which` failed with code $result") + } + child.inputReader().readLine().trim() + } else { + val output = wslDistribution.executeOnWsl(1000, "which", "mirrord") + if (output.exitCode != 0) { + throw RuntimeException("`which` failed with code ${output.exitCode}") + } + output.stdoutLines.first().trim() } - override fun onThrowable(error: Throwable) { - MirrordLogger.logger.debug(error) + val binary = MirrordBinary(output) + if (requiredVersion == null || requiredVersion == binary.version) { + return binary } + } catch (e: Exception) { + MirrordLogger.logger.debug("failed to find mirrord in path", e) } - ProgressManager.getInstance().run(versionCheckTask) + return null + } - val bytes = try { - environment.get() + /** + * @return executable found in plugin storage + */ + private fun findBinaryInStorage(requiredVersion: String?): MirrordBinary? { + try { + MirrordPathManager.getBinary(CLI_BINARY, true)?.let { + val binary = MirrordBinary(it) + if (requiredVersion == null || requiredVersion == binary.version) { + return binary + } + } } catch (e: Exception) { - throw RuntimeException("failed to download mirrord version $version", e) + MirrordLogger.logger.debug("failed to find mirrord in plugin storage", e) } - Files.createDirectories(destination.parent) - Files.write(destination, bytes) - destination.toFile().setExecutable(true) + return null } /** - * Fetches mirrord binary. - * Downloads and stores it in the plugin directory if necessary (local version is missing or outdated). + * @return the local installation of mirrord, either in `PATH` or in plugin storage + */ + private fun getLocalBinary(requiredVersion: String?, wslDistribution: WSLDistribution?): MirrordBinary? { + return findBinaryInPath(requiredVersion, wslDistribution) ?: findBinaryInStorage(requiredVersion) + } + + /** + * Finds a local installation of the mirrord binary. + * Schedules a binary update task to be executed in the background. * * @return the path to the binary - * - * @throws RuntimeException */ - fun getBinary(product: String, wslDistribution: WSLDistribution?): String { - val staleBinary = this.getLocalBinary(null, wslDistribution) + fun getBinary(product: String, wslDistribution: WSLDistribution?, project: Project): String? { + UpdateTask(project, product, wslDistribution, true).queue() - val timeout = if (staleBinary == null) 10L else 1L - val latestVersion = try { - this.getLatestSupportedVersion(product, Duration.ofSeconds(timeout)) - } catch (e: Exception) { - MirrordLogger.logger.debug("failed to check the latest supported version of the mirrord binary", e) - null - } - - latestVersion?.let { version -> + latestSupportedVersion?.let { version -> getLocalBinary(version, wslDistribution)?.let { return it.command } - - try { - val destinationPath = MirrordPathManager.getPath(CLI_BINARY, true) - downloadBinary(destinationPath, version) - return MirrordBinary(destinationPath.toString()).command - } catch (e: Exception) { - MirrordLogger.logger.debug("failed to download the mirrord binary", e) - } } - staleBinary?.let { - service + this.getLocalBinary(null, wslDistribution)?.let { + val message = latestSupportedVersion?.let { latest -> + "using a local installation with version ${it.version}, latest supported version is $latest" + } ?: "using a possibly outdated local installation with version ${it.version}" + + project + .service() .notifier - .notification( - "failed to download the mirrord binary, using a local installation with version ${it.version}", - NotificationType.WARNING - ) + .notification(message, NotificationType.WARNING) .withDontShowAgain(MirrordSettingsState.NotificationId.POSSIBLY_OUTDATED_BINARY_USED) .fire() return it.command } - throw RuntimeException("no local installation found and download failed") + return null } } diff --git a/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordExecManager.kt b/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordExecManager.kt index 6d9c8bbd..bc56e1d2 100644 --- a/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordExecManager.kt +++ b/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordExecManager.kt @@ -81,15 +81,14 @@ class MirrordExecManager(private val service: MirrordProjectService) { } private fun cliPath(wslDistribution: WSLDistribution?, product: String): String? { - val path = try { - service.binaryManager.getBinary(product, wslDistribution) - } catch (e: Exception) { - service.notifier.notifyRichError("failed to fetch mirrord binary: ${e.message}") - return null - } - wslDistribution?.let { - return it.getWslPath(path)!! + val path = service() + .getBinary(product, wslDistribution, service.project) + ?.let { wslDistribution?.getWslPath(it) ?: it } + + if (path == null) { + service.notifier.notifyRichError("no local installation of mirrord binary was found, download scheduled") } + return path } diff --git a/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordProjectService.kt b/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordProjectService.kt index 25120cfc..5a3193c5 100644 --- a/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordProjectService.kt +++ b/modules/core/src/main/kotlin/com/metalbear/mirrord/MirrordProjectService.kt @@ -17,8 +17,6 @@ class MirrordProjectService(val project: Project) : Disposable { val versionCheck: MirrordVersionCheck = MirrordVersionCheck(this) - val binaryManager: MirrordBinaryManager = MirrordBinaryManager(this) - val mirrordApi: MirrordApi = MirrordApi(this) val notifier: MirrordNotifier = MirrordNotifier(this) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 6ef016fb..e3c99691 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -42,6 +42,7 @@ +