Browse Source

Re-sign app after patching Info.plist

Resolves #602
pull/606/head
Alexey Tsvetkov 4 years ago committed by Alexey Tsvetkov
parent
commit
d1908bb41a
  1. 89
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ExternalToolRunner.kt
  2. 73
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacSigner.kt
  3. 67
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/files/MacJarSignFileCopyingProcessor.kt
  4. 6
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/validatePackageVersions.kt
  5. 37
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt
  6. 72
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractComposeDesktopTask.kt
  7. 54
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/gradle/DesktopApplicationTest.kt
  8. 1
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/TestProjects.kt
  9. 6
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/fileUtils.kt
  10. 49
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/processUtils.kt
  11. 37
      gradle-plugins/compose/src/test/test-projects/application/macSign/build.gradle
  12. BIN
      gradle-plugins/compose/src/test/test-projects/application/macSign/compose.test.keychain
  13. 10
      gradle-plugins/compose/src/test/test-projects/application/macSign/settings.gradle
  14. 2
      gradle-plugins/compose/src/test/test-projects/application/macSign/src/main/kotlin/main.kt

89
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ExternalToolRunner.kt

@ -0,0 +1,89 @@
/*
* 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.application.internal
import org.gradle.api.file.Directory
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.process.ExecOperations
import org.gradle.process.ExecResult
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
internal class ExternalToolRunner(
private val verbose: Property<Boolean>,
private val logsDir: Provider<Directory>,
private val execOperations: ExecOperations
) {
operator fun invoke(
tool: File,
args: Collection<String>,
environment: Map<String, Any> = emptyMap(),
workingDir: File? = null,
checkExitCodeIsNormal: Boolean = true,
processStdout: Function1<String, Unit>? = null,
forceLogToFile: Boolean = false
): ExecResult {
val logsDir = logsDir.ioFile
logsDir.mkdirs()
val toolName = tool.nameWithoutExtension
val logToConsole = verbose.get() && !forceLogToFile
val outFile = logsDir.resolve("${toolName}-${currentTimeStamp()}-out.txt")
val errFile = logsDir.resolve("${toolName}-${currentTimeStamp()}-err.txt")
val result = outFile.outputStream().buffered().use { outFileStream ->
errFile.outputStream().buffered().use { errFileStream ->
execOperations.exec { spec ->
spec.executable = tool.absolutePath
spec.args(*args.toTypedArray())
workingDir?.let { wd -> spec.workingDir(wd) }
spec.environment(environment)
// check exit value later
spec.isIgnoreExitValue = true
if (logToConsole) {
spec.standardOutput = spec.standardOutput.alsoOutputTo(outFileStream)
spec.errorOutput = spec.errorOutput.alsoOutputTo(errFileStream)
} else {
spec.standardOutput = outFileStream
spec.errorOutput = errFileStream
}
}
}
}
if (checkExitCodeIsNormal && result.exitValue != 0) {
val errMsg = buildString {
appendLine("External tool execution failed:")
val cmd = (listOf(tool.absolutePath) + args).joinToString(", ")
appendLine("* Command: [$cmd]")
appendLine("* Working dir: [${workingDir?.absolutePath.orEmpty()}]")
appendLine("* Exit code: ${result.exitValue}")
appendLine("* Standard output log: ${outFile.absolutePath}")
appendLine("* Error log: ${errFile.absolutePath}")
}
error(errMsg)
}
if (processStdout != null) {
processStdout(outFile.readText())
}
if (result.exitValue == 0) {
outFile.delete()
errFile.delete()
}
return result
}
private fun currentTimeStamp() =
LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss"))
}

73
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacSigner.kt

@ -0,0 +1,73 @@
/*
* 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.application.internal
import org.jetbrains.compose.desktop.application.internal.validation.ValidatedMacOSSigningSettings
import java.io.File
import java.util.regex.Pattern
internal class MacSigner(
val settings: ValidatedMacOSSigningSettings,
private val runExternalTool: ExternalToolRunner
) {
private lateinit var signKey: String
init {
runExternalTool(
MacUtils.security,
args = listOfNotNull(
"find-certificate",
"-a",
"-c",
settings.fullDeveloperID,
settings.keychain?.absolutePath
),
processStdout = { stdout ->
signKey = findCertificate(stdout)
}
)
}
fun sign(file: File) {
val args = arrayListOf(
"-vvvv",
"--timestamp",
"--options", "runtime",
"--force",
"--prefix", settings.prefix,
"--sign", signKey
)
settings.keychain?.let {
args.add("--keychain")
args.add(it.absolutePath)
}
args.add(file.absolutePath)
runExternalTool(MacUtils.codesign, args)
}
private fun findCertificate(certificates: String): String {
val regex = Pattern.compile("\"alis\"<blob>=\"([^\"]+)\"")
val m = regex.matcher(certificates)
if (!m.find()) {
val keychainPath = settings.keychain?.absolutePath
error(
"Could not find certificate for '${settings.identity}'" +
" in keychain [${keychainPath.orEmpty()}]"
)
}
val result = m.group(1)
if (m.find())
error(
"Multiple matching certificates are found for '${settings.fullDeveloperID}'. " +
"Please specify keychain containing unique matching certificate."
)
return result
}
}

67
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/files/MacJarSignFileCopyingProcessor.kt

@ -5,60 +5,24 @@
package org.jetbrains.compose.desktop.application.internal.files
import org.gradle.process.ExecOperations
import org.jetbrains.compose.desktop.application.internal.MacUtils
import org.jetbrains.compose.desktop.application.internal.MacSigner
import org.jetbrains.compose.desktop.application.internal.isJarFile
import org.jetbrains.compose.desktop.application.internal.validation.ValidatedMacOSSigningSettings
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.PrintStream
import java.util.regex.Pattern
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
internal class MacJarSignFileCopyingProcessor(
private val signer: MacSigner,
private val tempDir: File,
private val execOperations: ExecOperations,
private val signing: ValidatedMacOSSigningSettings
) : FileCopyingProcessor {
private val signKey: String
init {
val certificates = ByteArrayOutputStream().use { baos ->
PrintStream(baos).use { ps ->
execOperations.exec { exec ->
exec.executable = MacUtils.security.absolutePath
val args = arrayListOf("find-certificate", "-a", "-c", signing.fullDeveloperID)
signing.keychain?.let { args.add(it.absolutePath) }
exec.args(*args.toTypedArray())
exec.standardOutput = ps
}
}
baos.toString()
}
val regex = Pattern.compile("\"alis\"<blob>=\"([^\"]+)\"")
val m = regex.matcher(certificates)
if (!m.find()) {
val keychainPath = signing.keychain?.absolutePath
error(
"Could not find certificate for '${signing.identity}'" +
" in keychain [${keychainPath.orEmpty()}]"
)
}
signKey = m.group(1)
if (m.find()) error("Multiple matching certificates are found for '${signing.fullDeveloperID}'. " +
"Please specify keychain containing unique matching certificate.")
}
override fun copy(source: File, target: File) {
if (source.isJarFile) {
signNativeLibsInJar(source, target)
} else {
SimpleFileCopyingProcessor.copy(source, target)
if (source.name.isDylibPath) {
signDylib(target)
signer.sign(target)
}
}
}
@ -81,7 +45,7 @@ internal class MacJarSignFileCopyingProcessor(
val unpackedDylibFile = tempDir.resolve(sourceEntry.name.substringAfterLast("/"))
try {
zin.copyTo(unpackedDylibFile)
signDylib(unpackedDylibFile)
signer.sign(unpackedDylibFile)
val targetEntry = ZipEntry(sourceEntry.name).apply {
comment = sourceEntry.comment
extra = sourceEntry.extra
@ -95,29 +59,6 @@ internal class MacJarSignFileCopyingProcessor(
unpackedDylibFile.delete()
}
}
private fun signDylib(dylibFile: File) {
val args = arrayListOf(
"-vvvv",
"--timestamp",
"--options", "runtime",
"--force",
"--prefix", signing.prefix,
"--sign", signKey
)
signing.keychain?.let {
args.add("--keychain")
args.add(it.absolutePath)
}
args.add(dylibFile.absolutePath)
execOperations.exec { exec ->
exec.executable = MacUtils.codesign.absolutePath
exec.args(*args.toTypedArray())
}.assertNormalExitValue()
}
}
private val String.isDylibPath

6
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/validatePackageVersions.kt

@ -58,11 +58,11 @@ private class ErrorsCollector {
correctFormat: String? = null
) {
val msg = buildString {
appendln("* Illegal version for '$targetFormat': $error.")
appendLine("* Illegal version for '$targetFormat': $error.")
if (correctFormat != null) {
appendln(" * Correct format: $correctFormat")
appendLine(" * Correct format: $correctFormat")
}
appendln(" * You can specify the correct version using DSL properties: " +
appendLine(" * You can specify the correct version using DSL properties: " +
dslPropertiesFor(targetFormat).joinToString(", ")
)
}

37
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt

@ -172,12 +172,13 @@ abstract class AbstractJPackageTask @Inject constructor(
@get:Nested
internal var nonValidatedMacSigningSettings: MacOSSigningSettings? = null
private inline fun <T> withValidatedMacOSSigning(fn: (ValidatedMacOSSigningSettings) -> T): T? =
nonValidatedMacSigningSettings?.let { nonValidated ->
if (currentOS == OS.MacOS && nonValidated.sign.get()) {
fn(nonValidated.validate(nonValidatedMacBundleID))
} else null
}
private val macSigner: MacSigner? by lazy {
val nonValidatedSettings = nonValidatedMacSigningSettings
if (currentOS == OS.MacOS && nonValidatedSettings?.sign?.get() == true) {
val validatedSettings = nonValidatedSettings.validate(nonValidatedMacBundleID)
MacSigner(validatedSettings, runExternalTool)
} else null
}
@get:LocalState
protected val signDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/sign")
@ -272,11 +273,11 @@ abstract class AbstractJPackageTask @Inject constructor(
cliArg("--mac-package-name", macPackageName)
cliArg("--mac-package-identifier", nonValidatedMacBundleID)
withValidatedMacOSSigning { signing ->
macSigner?.let { signer ->
cliArg("--mac-sign", true)
cliArg("--mac-signing-key-user-name", signing.identity)
cliArg("--mac-signing-keychain", signing.keychain)
cliArg("--mac-package-signing-prefix", signing.prefix)
cliArg("--mac-signing-key-user-name", signer.settings.identity)
cliArg("--mac-signing-keychain", signer.settings.keychain)
cliArg("--mac-package-signing-prefix", signer.settings.prefix)
}
}
}
@ -324,16 +325,12 @@ abstract class AbstractJPackageTask @Inject constructor(
override fun prepareWorkingDir(inputChanges: InputChanges) {
val libsDir = libsDir.ioFile
val fileProcessor =
withValidatedMacOSSigning { signing ->
macSigner?.let { signer ->
val tmpDirForSign = signDir.ioFile
fileOperations.delete(tmpDirForSign)
tmpDirForSign.mkdirs()
MacJarSignFileCopyingProcessor(
tempDir = tmpDirForSign,
execOperations = execOperations,
signing = signing
)
MacJarSignFileCopyingProcessor(signer, tmpDirForSign)
} ?: SimpleFileCopyingProcessor
fun copyFileToLibsDir(sourceFile: File): File {
val targetFileName =
@ -388,7 +385,8 @@ abstract class AbstractJPackageTask @Inject constructor(
private fun patchInfoPlistIfNeeded() {
if (currentOS != OS.MacOS || targetFormat != TargetFormat.AppImage) return
val infoPlist = destinationDir.ioFile.resolve("${packageName.get()}.app/Contents/Info.plist")
val appDir = destinationDir.ioFile.resolve("${packageName.get()}.app/")
val infoPlist = appDir.resolve("Contents/Info.plist")
if (!infoPlist.exists()) return
val content = infoPlist.readText()
@ -405,12 +403,13 @@ abstract class AbstractJPackageTask @Inject constructor(
if (i >= 0) {
val newContent = buildString {
append(content.substring(0, i))
appendln(stringToAppend)
appendLine(stringToAppend)
append(" ")
appendln(content.substring(i, content.length))
appendLine(content.substring(i, content.length))
}
infoPlist.writeText(newContent)
}
macSigner?.sign(appDir)
}
override fun initState() {

72
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractComposeDesktopTask.kt

@ -16,7 +16,9 @@ import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.LocalState
import org.gradle.process.ExecOperations
import org.gradle.process.ExecResult
import org.jetbrains.compose.desktop.application.internal.*
import org.jetbrains.compose.desktop.application.internal.ComposeProperties
import org.jetbrains.compose.desktop.application.internal.ExternalToolRunner
import org.jetbrains.compose.desktop.application.internal.alsoOutputTo
import org.jetbrains.compose.desktop.application.internal.ioFile
import org.jetbrains.compose.desktop.application.internal.notNullProperty
@ -48,71 +50,7 @@ abstract class AbstractComposeDesktopTask : DefaultTask() {
})
}
internal fun runExternalTool(
tool: File,
args: Collection<String>,
environment: Map<String, Any> = emptyMap(),
workingDir: File? = null,
checkExitCodeIsNormal: Boolean = true,
processStdout: Function1<String, Unit>? = null,
forceLogToFile: Boolean = false
): ExecResult {
val logsDir = logsDir.ioFile
logsDir.mkdirs()
val toolName = tool.nameWithoutExtension
val logToConsole = verbose.get() && !forceLogToFile
val outFile = logsDir.resolve("${toolName}-${currentTimeStamp()}-out.txt")
val errFile = logsDir.resolve("${toolName}-${currentTimeStamp()}-err.txt")
val result = outFile.outputStream().buffered().use { outFileStream ->
errFile.outputStream().buffered().use { errFileStream ->
execOperations.exec { spec ->
spec.executable = tool.absolutePath
spec.args(*args.toTypedArray())
workingDir?.let { wd -> spec.workingDir(wd) }
spec.environment(environment)
// check exit value later
spec.isIgnoreExitValue = true
if (logToConsole) {
spec.standardOutput = spec.standardOutput.alsoOutputTo(outFileStream)
spec.errorOutput = spec.errorOutput.alsoOutputTo(errFileStream)
} else {
spec.standardOutput = outFileStream
spec.errorOutput = errFileStream
}
}
}
}
if (checkExitCodeIsNormal && result.exitValue != 0) {
val errMsg = buildString {
appendln("External tool execution failed:")
val cmd = (listOf(tool.absolutePath) + args).joinToString(", ")
appendln("* Command: [$cmd]")
appendln("* Working dir: [${workingDir?.absolutePath.orEmpty()}]")
appendln("* Exit code: ${result.exitValue}")
appendln("* Standard output log: ${outFile.absolutePath}")
appendln("* Error log: ${errFile.absolutePath}")
}
error(errMsg)
}
if (processStdout != null) {
processStdout(outFile.readText())
}
if (result.exitValue == 0) {
outFile.delete()
errFile.delete()
}
return result
}
private fun currentTimeStamp() =
LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss"))
@get:Internal
internal val runExternalTool: ExternalToolRunner
get() = ExternalToolRunner(verbose, logsDir, execOperations)
}

54
gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/gradle/DesktopApplicationTest.kt

@ -7,10 +7,7 @@ package org.jetbrains.compose.gradle
import org.gradle.internal.impldep.org.testng.Assert
import org.gradle.testkit.runner.TaskOutcome
import org.jetbrains.compose.desktop.application.internal.OS
import org.jetbrains.compose.desktop.application.internal.currentArch
import org.jetbrains.compose.desktop.application.internal.currentOS
import org.jetbrains.compose.desktop.application.internal.currentTarget
import org.jetbrains.compose.desktop.application.internal.*
import org.jetbrains.compose.test.*
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assumptions
@ -159,6 +156,55 @@ class DesktopApplicationTest : GradlePluginTestBase() {
}
}
@Test
fun testMacSign() {
Assumptions.assumeTrue(currentOS == OS.MacOS)
fun security(vararg args: Any): ProcessRunResult {
val args = args.map {
if (it is File) it.absolutePath else it.toString()
}
return runProcess(MacUtils.security, args)
}
fun withNewDefaultKeychain(newKeychain: File, fn: () -> Unit) {
val originalKeychain =
security("default-keychain")
.out
.trim()
.trim('"')
try {
security("default-keychain", "-s", newKeychain)
fn()
} finally {
security("default-keychain", "-s", originalKeychain)
}
}
with(testProject(TestProjects.macSign)) {
val keychain = file("compose.test.keychain")
val password = "compose.test"
withNewDefaultKeychain(keychain) {
security("default-keychain", "-s", keychain)
security("unlock-keychain", "-p", password, keychain)
gradle(":createDistributable").build().checks { check ->
check.taskOutcome(":createDistributable", TaskOutcome.SUCCESS)
val appDir = testWorkDir.resolve("build/compose/binaries/main/app/TestPackage.app/")
val result = runProcess(MacUtils.codesign, args = listOf("--verify", "--verbose", appDir.absolutePath))
val actualOutput = result.err.trim()
val expectedOutput = """
|${appDir.absolutePath}: valid on disk
|${appDir.absolutePath}: satisfies its Designated Requirement
""".trimMargin().trim()
Assert.assertEquals(expectedOutput, actualOutput)
}
}
}
}
@Test
fun testOptionsWithSpaces() {
with(testProject(TestProjects.optionsWithSpaces)) {

1
gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/TestProjects.kt

@ -12,6 +12,7 @@ object TestProjects {
const val moduleClashCli = "application/moduleClashCli"
const val javaLogger = "application/javaLogger"
const val macOptions = "application/macOptions"
const val macSign = "application/macSign"
const val optionsWithSpaces = "application/optionsWithSpaces"
const val unpackSkiko = "application/unpackSkiko"
const val jsMpp = "misc/jsMpp"

6
gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/fileUtils.kt

@ -16,11 +16,11 @@ fun File.modify(fn: (String) -> String) {
fun File.checkExists(): File = apply {
check(exists()) {
buildString {
appendln("Requested file does not exist: $absolutePath")
appendLine("Requested file does not exist: $absolutePath")
parentFile?.listFiles()?.let { siblingFiles ->
appendln("Other files in the same directory: ${parentFile.absolutePath}")
appendLine("Other files in the same directory: ${parentFile.absolutePath}")
siblingFiles.forEach {
appendln(" * ${it.name}")
appendLine(" * ${it.name}")
}
}
}

49
gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/processUtils.kt

@ -0,0 +1,49 @@
/*
* 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.test
import java.io.File
internal data class ProcessRunResult(val exitCode: Int, val out: String, val err: String)
internal fun runProcess(
tool: File,
args: Collection<String>,
checkExitCodeIsNormal: Boolean = true
): ProcessRunResult {
val outFile = File.createTempFile("run-process-compose-tests-out.txt", null).apply { deleteOnExit() }
val errFile = File.createTempFile("run-process-compose-tests-err.txt", null).apply { deleteOnExit() }
return try {
val cmd = arrayOf(tool.absolutePath, *args.toTypedArray())
val process = ProcessBuilder().run {
redirectError(errFile)
redirectOutput(outFile)
command(*cmd)
start()
}
val exitCode = process.waitFor()
if (checkExitCodeIsNormal) {
check(exitCode == 0) {
buildString {
appendLine("Non-zero exit code: $exitCode")
appendLine("Command: ${cmd.joinToString(", ")}")
appendLine("Out:")
outFile.forEachLine { line ->
appendLine(" >$line")
}
appendLine("Err:")
errFile.forEachLine { line ->
appendLine(" >$line")
}
}
}
}
ProcessRunResult(exitCode = exitCode, out = outFile.readText(), err = errFile.readText())
} finally {
outFile.delete()
errFile.delete()
}
}

37
gradle-plugins/compose/src/test/test-projects/application/macSign/build.gradle

@ -0,0 +1,37 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
id "org.jetbrains.kotlin.jvm"
id "org.jetbrains.compose"
}
repositories {
google()
mavenCentral()
maven {
url "https://maven.pkg.jetbrains.space/public/p/compose/dev"
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib"
implementation compose.desktop.currentOs
}
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
packageName = "TestPackage"
macOS {
bundleID = "signing.test.package"
signing {
sign.set(true)
identity.set("Compose Test")
keychain.set(project.file("compose.test.keychain").absolutePath)
}
}
}
}
}

BIN
gradle-plugins/compose/src/test/test-projects/application/macSign/compose.test.keychain

Binary file not shown.

10
gradle-plugins/compose/src/test/test-projects/application/macSign/settings.gradle

@ -0,0 +1,10 @@
pluginManagement {
plugins {
id 'org.jetbrains.kotlin.jvm' version 'KOTLIN_VERSION_PLACEHOLDER'
id 'org.jetbrains.compose' version 'COMPOSE_VERSION_PLACEHOLDER'
}
repositories {
mavenLocal()
gradlePluginPortal()
}
}

2
gradle-plugins/compose/src/test/test-projects/application/macSign/src/main/kotlin/main.kt

@ -0,0 +1,2 @@
fun main() {
}
Loading…
Cancel
Save