diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutable.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutable.kt index 7ec40e794c0..5bc8d12c091 100644 --- a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutable.kt +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutable.kt @@ -15,43 +15,11 @@ */ package com.google.firebase.dataconnect.gradle.plugin -import java.io.InputStream -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromStream - sealed interface DataConnectExecutable { data class File(val file: java.io.File) : DataConnectExecutable data class RegularFile(val file: org.gradle.api.file.RegularFile) : DataConnectExecutable - data class Version(val version: String) : DataConnectExecutable { - companion object { - val default: Version - get() = Version(VersionsJson.load().default) - } - } - - @OptIn(ExperimentalSerializationApi::class) - object VersionsJson { - - const val RESOURCE_PATH = - "com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersions.json" - - fun load(): Root = openFile().use { Json.decodeFromStream(it) } - - private fun openFile(): InputStream = - this::class.java.classLoader.getResourceAsStream(RESOURCE_PATH) - ?: throw DataConnectGradleException("antkaw2gjp", "resource not found: $RESOURCE_PATH") - - @kotlinx.serialization.Serializable - data class Root( - val default: String, - val versions: Map, - ) - - @kotlinx.serialization.Serializable - data class VerificationInfo(val size: Long, val sha512DigestHex: String) - } + data class Version(val version: String) : DataConnectExecutable } diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableDownloadTask.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableDownloadTask.kt index c0268edbb39..ca15458e4c0 100644 --- a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableDownloadTask.kt +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableDownloadTask.kt @@ -23,9 +23,8 @@ import java.util.regex.Pattern import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit import kotlin.time.toDuration -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import org.gradle.api.DefaultTask +import org.gradle.api.Task import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property @@ -42,6 +41,8 @@ abstract class DataConnectExecutableDownloadTask : DefaultTask() { @get:Input @get:Optional abstract val version: Property + @get:Input @get:Optional abstract val operatingSystem: Property + @get:Internal abstract val buildDirectory: DirectoryProperty @get:OutputFile abstract val outputFile: RegularFileProperty @@ -50,11 +51,13 @@ abstract class DataConnectExecutableDownloadTask : DefaultTask() { fun run() { val inputFile: File? = inputFile.orNull?.asFile val version: String? = version.orNull + val operatingSystem: OperatingSystem = operatingSystem.get() val buildDirectory: File = buildDirectory.get().asFile val outputFile: File = outputFile.get().asFile logger.info("inputFile: {}", inputFile) logger.info("version: {}", version) + logger.info("operatingSystem: {}", operatingSystem) logger.info("buildDirectory: {}", buildDirectory) logger.info("outputFile: {}", outputFile) @@ -71,8 +74,8 @@ abstract class DataConnectExecutableDownloadTask : DefaultTask() { } else if (inputFile !== null) { runWithFile(inputFile = inputFile, outputFile = outputFile) } else if (version !== null) { - runWithVersion(version = version, outputFile = outputFile) - verifyOutputFile(outputFile, version) + downloadDataConnectExecutable(version, operatingSystem, outputFile) + verifyOutputFile(outputFile, operatingSystem, version) } else { throw DataConnectGradleException( "chc94cq7vx", @@ -82,66 +85,73 @@ abstract class DataConnectExecutableDownloadTask : DefaultTask() { } } - private fun verifyOutputFile(outputFile: File, version: String) { + private fun verifyOutputFile( + outputFile: File, + operatingSystem: OperatingSystem, + version: String + ) { logger.info("Verifying file size and SHA512 digest of file: {}", outputFile) val fileInfo = FileInfo.forFile(outputFile) - val verificationInfoJsonString = - jsonPrettyPrint.encodeToString( - DataConnectExecutable.VersionsJson.VerificationInfo( - size = fileInfo.sizeInBytes, - sha512DigestHex = fileInfo.sha512DigestHex, - ) - ) + val allVersions = DataConnectExecutableVersionsRegistry.load().versions + val allVersionNames = + allVersions + .asSequence() + .filter { it.os == operatingSystem } + .map { it.version } + .distinct() + .sorted() + .joinToString(", ") + val applicableVersions = + allVersions.filter { it.version == version && it.os == operatingSystem } - val verificationInfoByVersion = DataConnectExecutable.VersionsJson.load().versions - val verificationInfo = verificationInfoByVersion[version] - if (verificationInfo === null) { + if (applicableVersions.isEmpty()) { val message = - "verification information for ${outputFile.absolutePath}" + - " (version $version) is not known; known versions are: " + - verificationInfoByVersion.keys.sorted().joinToString(", ") + "verification information for Data Connect toolkit executable" + + " version $version for $operatingSystem is not known;" + + " known versions for $operatingSystem are: $allVersionNames" + + " (loaded from ${DataConnectExecutableVersionsRegistry.PATH})" logger.error("ERROR: $message") - logger.error( - "To update ${DataConnectExecutable.VersionsJson.RESOURCE_PATH} with" + - " information about this version, add this JSON blob: $verificationInfoJsonString" - ) throw DataConnectGradleException("ym8assbfgw", message) + } else if (applicableVersions.size > 1) { + val message = + "INTERNAL ERROR: ${applicableVersions.size} verification information records for" + + " Data Connect toolkit executable version $version for $operatingSystem were found in" + + " ${DataConnectExecutableVersionsRegistry.PATH}, but expected exactly 1" + logger.error("ERROR: $message") + throw DataConnectGradleException("zyw5xrky6e", message) } + val versionInfo = applicableVersions.single() val verificationErrors = mutableListOf() - if (fileInfo.sizeInBytes != verificationInfo.size) { + if (fileInfo.sizeInBytes != versionInfo.size) { logger.error( "ERROR: File ${outputFile.absolutePath} has an unexpected size (in bytes): actual is " + fileInfo.sizeInBytes.toStringWithThousandsSeparator() + " but expected " + - verificationInfo.size.toStringWithThousandsSeparator() + versionInfo.size.toStringWithThousandsSeparator() ) verificationErrors.add("file size mismatch") } - if (fileInfo.sha512DigestHex != verificationInfo.sha512DigestHex) { + if (fileInfo.sha512DigestHex != versionInfo.sha512DigestHex) { logger.error( "ERROR: File ${outputFile.absolutePath} has an unexpected SHA512 digest:" + " actual is ${fileInfo.sha512DigestHex}" + - " but expected ${verificationInfo.sha512DigestHex}" + " but expected ${versionInfo.sha512DigestHex}" ) verificationErrors.add("SHA512 digest mismatch") } - if (verificationErrors.isEmpty()) { - logger.info("Verifying file size and SHA512 digest succeeded") - return + if (verificationErrors.isNotEmpty()) { + val errorMessage = + "Verification of ${outputFile.absolutePath}" + + " (version=${versionInfo.version} os=${versionInfo.os}) failed:" + + " ${verificationErrors.joinToString(", ")}" + logger.error(errorMessage) + throw DataConnectGradleException("x9dfwhjr9c", errorMessage) } - logger.error( - "To update ${DataConnectExecutable.VersionsJson.RESOURCE_PATH} with" + - " information about this version, add this JSON blob: $verificationInfoJsonString" - ) - - throw DataConnectGradleException( - "x9dfwhjr9c", - "Verification of ${outputFile.absolutePath} failed: ${verificationErrors.joinToString(", ")}" - ) + logger.info("Verifying file size and SHA512 digest succeeded") } data class FileInfo(val sizeInBytes: Long, val sha512DigestHex: String) { @@ -181,62 +191,73 @@ abstract class DataConnectExecutableDownloadTask : DefaultTask() { } } - private fun runWithVersion(version: String, outputFile: File) { - val fileName = "dataconnect-emulator-linux-v$version" - val url = URL("https://storage.googleapis.com/firemat-preview-drop/emulator/$fileName") + companion object { + fun Task.downloadDataConnectExecutable( + version: String, + operatingSystem: OperatingSystem, + outputFile: File + ) { + val osName = + when (operatingSystem) { + OperatingSystem.Windows -> "windows" + OperatingSystem.MacOS -> "macos" + OperatingSystem.Linux -> "linux" + } + val downloadFileName = "dataconnect-emulator-$osName-v$version" + val url = + URL("https://storage.googleapis.com/firemat-preview-drop/emulator/$downloadFileName") - logger.info("Downloading {} to {}", url, outputFile) - project.mkdir(outputFile.parentFile) + logger.info("Downloading {} to {}", url, outputFile) + project.mkdir(outputFile.parentFile) - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" - val responseCode = connection.responseCode - if (responseCode != HttpURLConnection.HTTP_OK) { - throw DataConnectGradleException( - "n3mj6ahxwt", - "Downloading Data Connect executable from $url failed with HTTP response code" + - " $responseCode: ${connection.responseMessage}" + - " (expected HTTP response code ${HttpURLConnection.HTTP_OK})" - ) - } - - val startTime = System.nanoTime() - val debouncer = Debouncer(5.seconds) - outputFile.outputStream().use { oStream -> - var downloadByteCount: Long = 0 - fun logDownloadedBytes() { - val elapsedTime = (System.nanoTime() - startTime).toDuration(DurationUnit.NANOSECONDS) - logger.info( - "Downloaded {} bytes in {}", - downloadByteCount.toStringWithThousandsSeparator(), - elapsedTime + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + throw DataConnectGradleException( + "n3mj6ahxwt", + "Downloading Data Connect executable from $url failed with HTTP response code" + + " $responseCode: ${connection.responseMessage}" + + " (expected HTTP response code ${HttpURLConnection.HTTP_OK})" ) } - connection.inputStream.use { iStream -> - val buffer = ByteArray(8192) - while (true) { - val readCount = iStream.read(buffer) - if (readCount < 0) { - break + + val startTime = System.nanoTime() + val debouncer = Debouncer(5.seconds) + outputFile.outputStream().use { oStream -> + var downloadByteCount: Long = 0 + fun logDownloadedBytes() { + val elapsedTime = (System.nanoTime() - startTime).toDuration(DurationUnit.NANOSECONDS) + logger.info( + "Downloaded {} bytes in {}", + downloadByteCount.toStringWithThousandsSeparator(), + elapsedTime + ) + } + connection.inputStream.use { iStream -> + val buffer = ByteArray(8192) + while (true) { + val readCount = iStream.read(buffer) + if (readCount < 0) { + break + } + downloadByteCount += readCount + debouncer.maybeRun(::logDownloadedBytes) + oStream.write(buffer, 0, readCount) } - downloadByteCount += readCount - debouncer.maybeRun(::logDownloadedBytes) - oStream.write(buffer, 0, readCount) } + logDownloadedBytes() } - logDownloadedBytes() - } - project.exec { execSpec -> - execSpec.run { - executable = "chmod" - args = listOf("a+x", outputFile.absolutePath) + if (operatingSystem != OperatingSystem.Windows) { + project.exec { execSpec -> + execSpec.run { + executable = "chmod" + args = listOf("a+x", outputFile.absolutePath) + } + } } } } - - private companion object { - val jsonPrettyPrint = Json { prettyPrint = true } - } } diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersionRegistry.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersionRegistry.kt new file mode 100644 index 00000000000..7f95885c4cd --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersionRegistry.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import java.io.InputStream +import java.io.OutputStream +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream + +@OptIn(ExperimentalSerializationApi::class) +object DataConnectExecutableVersionsRegistry { + + const val PATH = + "com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersions.json" + + private val json: Json by lazy { + Json { + prettyPrint = true + prettyPrintIndent = " " + } + } + + fun load(): Root = openResourceForReading().use { load(it) } + + fun load(file: java.io.File): Root = file.inputStream().use { load(it) } + + fun load(stream: InputStream): Root = json.decodeFromStream(stream) + + fun save(root: Root, dest: java.io.File) = dest.outputStream().use { save(root, it) } + + fun save(root: Root, dest: OutputStream) { + json.encodeToStream(root, dest) + } + + private fun openResourceForReading(): InputStream = + this::class.java.classLoader.getResourceAsStream(PATH) + ?: throw DataConnectGradleException("antkaw2gjp", "resource not found: $PATH") + + @Serializable + data class Root( + val defaultVersion: String, + val versions: List, + ) + + @Serializable + data class VersionInfo( + val version: String, + @Serializable(with = OperatingSystemSerializer::class) val os: OperatingSystem, + val size: Long, + val sha512DigestHex: String, + ) + + private object OperatingSystemSerializer : KSerializer { + override val descriptor = + PrimitiveSerialDescriptor( + "com.google.firebase.dataconnect.gradle.plugin.OperatingSystem", + PrimitiveKind.STRING, + ) + + override fun deserialize(decoder: Decoder): OperatingSystem = + when (val name = decoder.decodeString()) { + "windows" -> OperatingSystem.Windows + "macos" -> OperatingSystem.MacOS + "linux" -> OperatingSystem.Linux + else -> + throw DataConnectGradleException( + "nd5z2jk4hr", + "Unknown operating system: $name (must be windows, macos, or linux)" + ) + } + + override fun serialize(encoder: Encoder, value: OperatingSystem) = + encoder.encodeString( + when (value) { + OperatingSystem.Windows -> "windows" + OperatingSystem.MacOS -> "macos" + OperatingSystem.Linux -> "linux" + } + ) + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradlePlugin.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradlePlugin.kt index e53eb432322..bbeab8fe579 100644 --- a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradlePlugin.kt +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradlePlugin.kt @@ -110,6 +110,7 @@ abstract class DataConnectGradlePlugin : Plugin { } ) ) + operatingSystem.set(dataConnectProviders.operatingSystem) outputFile.set( dataConnectExecutable.map { when (it) { @@ -117,7 +118,10 @@ abstract class DataConnectGradlePlugin : Plugin { is DataConnectExecutable.RegularFile -> inputFile.get() is DataConnectExecutable.Version -> buildDirectory - .map { directory -> directory.file("dataconnect-v${it.version}") } + .map { directory -> + val os = dataConnectProviders.operatingSystem.get() + directory.file("dataconnect-v${it.version}${os.executableSuffix}") + } .get() } } diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectProviders.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectProviders.kt index 5ece4c1413e..9100bcb7f1b 100644 --- a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectProviders.kt +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectProviders.kt @@ -45,14 +45,22 @@ class DataConnectProviders( val valueFromProject: Provider = project.provider { projectExtension.dataConnectExecutable } + val defaultVersion: Provider = + project.provider { + val root = DataConnectExecutableVersionsRegistry.load() + DataConnectExecutable.Version(root.defaultVersion) + } + valueFromLocalSettings .orElse(fileValueFromGradleProperty) .orElse(versionValueFromGradleProperty) .orElse(valueFromVariant) .orElse(valueFromProject) - .orElse(DataConnectExecutable.Version.default) + .orElse(defaultVersion) } + val operatingSystem: Provider = project.provider { OperatingSystem.current() } + val postgresConnectionUrl: Provider = run { val gradlePropertyName = "dataconnect.emulator.postgresConnectionUrl" val valueFromLocalSettings: Provider = localSettings.postgresConnectionUrl diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/OperatingSystem.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/OperatingSystem.kt new file mode 100644 index 00000000000..85828b5db5b --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/OperatingSystem.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.gradle.plugin + +import java.util.Locale + +enum class OperatingSystem(val executableSuffix: String) { + Windows(".exe"), + MacOS(""), + Linux(""); + + companion object { + fun current(): OperatingSystem? { + val osName = System.getProperty("os.name") + return if (osName === null) null else forName(osName) + } + + fun forName(os: String): OperatingSystem? = forNameWithLowercaseOS(os.lowercase(Locale.ROOT)) + + // This logic was adapted from + // https://github.com/gradle/gradle/blob/4457734e73/platforms/core-runtime/base-services/src/main/java/org/gradle/internal/os/OperatingSystem.java#L64-L82 + private fun forNameWithLowercaseOS(os: String): OperatingSystem? = + if (os.contains("windows")) { + Windows + } else if (os.contains("mac os x") || os.contains("darwin") || os.contains("osx")) { + MacOS + } else if (os.contains("linux")) { + Linux + } else { + null + } + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/resources/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersions.json b/firebase-dataconnect/gradleplugin/plugin/src/main/resources/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersions.json index fc6316f2566..f6462b84356 100644 --- a/firebase-dataconnect/gradleplugin/plugin/src/main/resources/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersions.json +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/resources/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableVersions.json @@ -1,45 +1,185 @@ { - "default": "1.4.3", - "versions": { - "1.3.4": { + "defaultVersion": "1.4.3", + "versions": [ + { + "version": "1.3.4", + "os": "windows", + "size": 24631296, + "sha512DigestHex": "5d59a75511cfddca4708a47fe23674daa04fd0567dff8b1af2a78d56e06c97ba5f8cf0d2c1163a4ca512d6b24ae7194b4630367a3158abf890c062e065f2886d" + }, + { + "version": "1.3.4", + "os": "macos", + "size": 24216320, + "sha512DigestHex": "446c05014f1ba59debd04cb43da1c4d6f9b297061594ea87ffdd169ccd30ef80411867bdc36ea2242e644f0657ae686aa2f8ce1be1c383d156f20ce269acb149" + }, + { + "version": "1.3.4", + "os": "linux", "size": 24125592, "sha512DigestHex": "3ec9317db593ebeacfea9756cdd08a02849296fbab67f32f3d811a766be6ce2506fc7a0cf5f5ea880926f0c4defa5ded965268f5dfe5d07eb80cef926f216c7e" }, - "1.3.5": { + { + "version": "1.3.5", + "os": "windows", + "size": 24651264, + "sha512DigestHex": "747cefde377a2a2f13770e7c1873cea7b3410b6e29e5f1b156da98752a2872b5c34a67c355ac4e9d60367bb3abd25ee1313cb4f0c0ae2f603d077f12a45f0702" + }, + { + "version": "1.3.5", + "os": "macos", + "size": 24232704, + "sha512DigestHex": "1a94e8df220ae30139d0a6a5d5b8920e87f7d09761f2c68fb38cf5376d5a8b11df6dcc471a6b04d05adbffa19e1d98bc6fd96e57eae917e6c015b501fd33769f" + }, + { + "version": "1.3.5", + "os": "linux", "size": 24146072, "sha512DigestHex": "630391e3c50568cca36e562e51b300e673fa7190c0cae0475a03e4af4003babe71198c5b0309ecd261b3a3362e8c4d49bdb6cbc6f2b2d3297444112a018a0c10" }, - "1.3.6": { + { + "version": "1.3.6", + "os": "windows", + "size": 25292288, + "sha512DigestHex": "85b391e484ade85c51f71f95a7cb0d742e7ccf83472381337537e4203e9c0e79f81d85ddc26d16866640a6e0fc8bbaa84d81dfadd6e23d2df8ad2d37b991c529" + }, + { + "version": "1.3.6", + "os": "macos", + "size": 24867584, + "sha512DigestHex": "d48a993135b855a25cbd322d073bfc9095dd12d33fb15d96a3d56172d86efd87177d70a60b86977d9d87af1eb807ac898fd1e5eeecd116591ca58f355d117af2" + }, + { + "version": "1.3.6", + "os": "linux", "size": 24785048, "sha512DigestHex": "77b2fd79a8a70e47defb1592a092c63642fda6c33715f1977d7a44daed3d7e181c3870aad0fee7b035aabea7778a244135ab3e633247ccd5f937105f6d495a26" }, - "1.3.7": { + { + "version": "1.3.7", + "os": "windows", + "size": 25441792, + "sha512DigestHex": "e1ab1e2d6adef4a26956c67cefe75ebc36f5add449647742d5c3e144a343c2d9258a17a387a2241031ebb2e6a5beae9086fb27e1dc9b1fb3715108178cb819df" + }, + { + "version": "1.3.7", + "os": "macos", + "size": 25019136, + "sha512DigestHex": "7874bbb58307a13f1e32dbd522605bab5602402b486488861133c52531af54ef7b6846a6fa71880e06f6c4a85b035d28f396989b0cc0f0069e4f7b8839452bfd" + }, + { + "version": "1.3.7", + "os": "linux", "size": 24928408, "sha512DigestHex": "99d9774f3b29a6845f0e096893d1205e69b6f8654797a3fc7d54d22e8f7059d1b6549ae23b8e8f18c952c1c7d25a07b0b8b29a957abd97e1a79c703448497cef" }, - "1.3.8": { + { + "version": "1.3.8", + "os": "windows", + "size": 25450496, + "sha512DigestHex": "9dd7db9cab14a5121d9ca07ca2e56c8c7a6bb9853464a34409ff97e627dc86c72b3c65a10a3a7e70be18c6fd52a98a6ab9e9a44935d8832a81d8627afc889030" + }, + { + "version": "1.3.8", + "os": "macos", + "size": 25027328, + "sha512DigestHex": "c4be1d1b5ea4139324a24165a1ff83398e0b9a1ab8c3a9cf0869b9f3ca05ee4e71bfeb527301e700b9a9d224a8ece7b3f831953338a193361de27ad40a17001a" + }, + { + "version": "1.3.8", + "os": "linux", "size": 24940696, "sha512DigestHex": "aea3583ebe1a36938eec5164de79405951ddf05b70a857ddb4f346f1424666f1d96989a5f81326c7e2aef4a195d31ff356fdf2331ed98fa1048c4bd469cbfd97" }, - "1.3.9": { + { + "version": "1.3.9", + "os": "windows", + "size": 25489920, + "sha512DigestHex": "5fc6572bd2364d7bf371351ed480da1aff4ae52b534219dc3e5aa504cc7f16cfd262f4c6f9eafe325ce7386daeac9b55318c06773b61550593531947efee18a0" + }, + { + "version": "1.3.9", + "os": "macos", + "size": 25064192, + "sha512DigestHex": "56a6155c99a344a5ab6fcaeb53d8cedec04b93a3643593af195ee9f581f5a237fee1b75472ed6c266068f93c571484e324a4714e9a8f16f36f2169b013d02fc7" + }, + { + "version": "1.3.9", + "os": "linux", "size": 24977560, "sha512DigestHex": "4558928c2a84b54113e0d6918907eb75bdeb9bd059dcc4b6f22cb4a7c9c7421a3577f3b0d2eeb246b1df739b38f1eb91e5a6166b0e559707746d79e6ccdf9ed4" }, - "1.4.0": { + { + "version": "1.4.0", + "os": "windows", + "size": 25529344, + "sha512DigestHex": "0fdc789ff4a5540e9dc4bd0443bb933702defd9426c991d3bb48a2c97b307d4aabd3c5471d03c2c86fa10f4595284c093aff319ec1ad18ac3eed744a8349f6e5" + }, + { + "version": "1.4.0", + "os": "macos", + "size": 25105152, + "sha512DigestHex": "823be9718ab4441034583170666a5e5b6d4ab85f4255a3c4ec1064904307fdbff202eb684e903208de96ad2220ad18c133733ff58fce4aa87fdefe3d3837d8cd" + }, + { + "version": "1.4.0", + "os": "linux", "size": 25018520, "sha512DigestHex": "c06ccade89cb46459452f71c6d49a01b4b30c9f96cc4cb770ed168e7420ef0cb368cd602ff596137e6586270046cf0ffd9f8d294e44b036e5c5b373a074b7e5a" }, - "1.4.1": { + { + "version": "1.4.1", + "os": "windows", + "size": 25548288, + "sha512DigestHex": "813ed9fb16178a922bb238b2edb52318039bbd47c13241476d8f9f7ea32fc79a94d92007344234517e3d259915d6225a97486eb216e18b79b2f00b190eb05d43" + }, + { + "version": "1.4.1", + "os": "macos", + "size": 25125632, + "sha512DigestHex": "e449d3ffa09afa6162573ba08d216568b0e04045042421e37fbbabd46388e84f37150a4e210ceffe5897df2011be379fb3d84c628adf75d9558b742facd251b9" + }, + { + "version": "1.4.1", + "os": "linux", "size": 25034904, "sha512DigestHex": "f4a16aca3a68c431407fc88a900940c73612a0046d9603ca80195c8c9641ee38fd81b67cc158af600e173de1abc3cb0df9377b1a6012c808ab0871bb1bdbc0b1" }, - "1.4.2": { + { + "version": "1.4.2", + "os": "windows", + "size": 25548800, + "sha512DigestHex": "2500796c2686ec48f34f258a7fee0dcf10bc1aa6cded97df38120d8e971f0febbaf42ee8cc0b400f3b3c25b03f6a3f780a37ef3b11ffc99783b2e172c32d1ba0" + }, + { + "version": "1.4.2", + "os": "macos", + "size": 25125632, + "sha512DigestHex": "953d6a0f1fcedfb5cc86d207c4c8cf0cad742d10deed4867b3408be3a94c80b1ae692c06885d97f34b871ddfc005a01da18e99de2e5d6d1c9cac464ef1eea63f" + }, + { + "version": "1.4.2", + "os": "linux", "size": 25034904, "sha512DigestHex": "24ee2db55a034dcb95000715919e1dc35c91403000dbd3b912e6b5b55587b862eca886bb1ca86e19cdaa25c77c29492e5d3b0c740c8649a90297cf84e9c9123b" }, - "1.4.3": { + { + "version": "1.4.3", + "os": "windows", + "size": 25548800, + "sha512DigestHex": "87ba7d97c09671dd35ead50b86075cffc23735703a74d57627b8c5f6575e27ad9419e447a272ef3b50d7caafe8e4ad230b572ee74fd2a32da42dfad6382ebd8c" + }, + { + "version": "1.4.3", + "os": "macos", + "size": 25125632, + "sha512DigestHex": "52c34094de28a9610daf5ecdb0c13fad07ed181a13efda4c950e2a0509ca2b9f177b244cfca853f39d30ddee27e38756ebd76b87dd1fd89753b7b3cd9d1bf466" + }, + { + "version": "1.4.3", + "os": "linux", "size": 25034904, "sha512DigestHex": "c25fd2cb9ef4896cadc05fab79f767f8fc8212e3b967f2ae535855befd63339539a4f6cd648743c024f40139b668cc69fb9c6691490259664af5821d116896cf" } - } -} + ] +} \ No newline at end of file