diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/InfoPlistBuilder.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/InfoPlistBuilder.kt new file mode 100644 index 0000000000..9727216e51 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/InfoPlistBuilder.kt @@ -0,0 +1,59 @@ +/* + * 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 java.io.File +import kotlin.reflect.KProperty + +internal class InfoPlistBuilder { + private val values = LinkedHashMap() + + operator fun get(key: InfoPlistKey): String? = values[key] + operator fun set(key: InfoPlistKey, value: String) { + values[key] = value + } + + fun writeToFile(file: File) { + file.writer().buffered().use { writer -> + writer.run { + appendLine("") + appendLine("") + appendLine("") + appendLine(" ") + for ((k, v) in values) { + appendLine(" ${k.name}") + appendLine(" $v") + } + appendLine(" ") + appendLine("") + } + } + } +} + +internal data class InfoPlistKey(val name: String) + +internal object PlistKeys { + private operator fun getValue(thisRef: PlistKeys, property: KProperty<*>): InfoPlistKey = + InfoPlistKey(property.name) + + val LSMinimumSystemVersion by this + val CFBundleDevelopmentRegion by this + val CFBundleAllowMixedLocalizations by this + val CFBundleExecutable by this + val CFBundleIconFile by this + val CFBundleIdentifier by this + val CFBundleInfoDictionaryVersion by this + val CFBundleName by this + val CFBundlePackageType by this + val CFBundleShortVersionString by this + val CFBundleSignature by this + val LSApplicationCategoryType by this + val CFBundleVersion by this + val NSHumanReadableCopyright by this + val NSSupportsAutomaticGraphicsSwitching by this + val NSHighResolutionCapable by this +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/packageVersions.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/packageVersions.kt index 0d3ed03722..e2bed7c38d 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/packageVersions.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/packageVersions.kt @@ -19,6 +19,7 @@ internal fun packageVersionFor( project.provider { app.nativeDistributions.packageVersionFor(targetFormat) ?: project.version.toString().takeIf { it != "unspecified" } + ?: "1.0.0" } private fun NativeDistributions.packageVersionFor( diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt index 728f6c4059..6def4e1637 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt @@ -25,6 +25,7 @@ import org.jetbrains.compose.desktop.application.internal.files.transformJar import org.jetbrains.compose.desktop.application.internal.validation.ValidatedMacOSSigningSettings import org.jetbrains.compose.desktop.application.internal.validation.validate import java.io.* +import java.nio.file.Files import java.util.* import java.util.zip.ZipEntry import javax.inject.Inject @@ -183,6 +184,9 @@ abstract class AbstractJPackageTask @Inject constructor( @get:LocalState protected val signDir: Provider = project.layout.buildDirectory.dir("compose/tmp/sign") + @get:LocalState + protected val resourcesDir: Provider = project.layout.buildDirectory.dir("compose/tmp/resources") + @get:LocalState protected val skikoDir: Provider = project.layout.buildDirectory.dir("compose/tmp/skiko") @@ -204,6 +208,7 @@ abstract class AbstractJPackageTask @Inject constructor( // Args, that can only be used, when creating an app image or an installer w/o --app-image parameter cliArg("--input", libsDir) cliArg("--runtime-image", runtimeImage) + cliArg("--resource-dir", resourcesDir) val mappedJar = libsMapping[launcherMainJar.ioFile]?.singleOrNull() ?: error("Main jar was not processed correctly: ${launcherMainJar.ioFile}") @@ -352,6 +357,14 @@ abstract class AbstractJPackageTask @Inject constructor( listOf(copyFileToLibsDir(sourceFile)) } } + + fileOperations.delete(resourcesDir) + fileOperations.mkdir(resourcesDir) + if (currentOS == OS.MacOS) { + InfoPlistBuilder() + .also { setInfoPlistValues(it) } + .writeToFile(resourcesDir.ioFile.resolve("Info.plist")) + } } override fun jvmToolEnvironment(): MutableMap = @@ -367,51 +380,10 @@ abstract class AbstractJPackageTask @Inject constructor( override fun checkResult(result: ExecResult) { super.checkResult(result) - patchInfoPlistIfNeeded() val outputFile = findOutputFileOrDir(destinationDir.ioFile, targetFormat) logger.lifecycle("The distribution is written to ${outputFile.canonicalPath}") } - /** - * https://github.com/JetBrains/compose-jb/issues/545 - * - * Patching Info.plist is necessary to avoid duplicating and supporting - * properties set by jpackage. - * - * Info.plist is patched only on macOS for app image. - * Packaged installers receive patched Info.plist through - * prebuilt [appImage]. - */ - private fun patchInfoPlistIfNeeded() { - if (currentOS != OS.MacOS || targetFormat != TargetFormat.AppImage) return - - val appDir = destinationDir.ioFile.resolve("${packageName.get()}.app/") - val infoPlist = appDir.resolve("Contents/Info.plist") - if (!infoPlist.exists()) return - - val content = infoPlist.readText() - val nsSupportsAutomaticGraphicsSwitching = "NSSupportsAutomaticGraphicsSwitching" - val stringToAppend = "$nsSupportsAutomaticGraphicsSwitching" - if (content.indexOf(nsSupportsAutomaticGraphicsSwitching) >= 0) return - - /** - * Dirty hack: to avoid parsing plist file, let's find known expected key substring, - * and insert the necessary keys before it. - */ - val knownExpectedKey = "NSHighResolutionCapable" - val i = content.indexOf(knownExpectedKey) - if (i >= 0) { - val newContent = buildString { - append(content.substring(0, i)) - appendLine(stringToAppend) - append(" ") - appendLine(content.substring(i, content.length)) - } - infoPlist.writeText(newContent) - } - macSigner?.sign(appDir) - } - override fun initState() { val mappingFile = libsMappingFile.ioFile if (mappingFile.exists()) { @@ -430,6 +402,32 @@ abstract class AbstractJPackageTask @Inject constructor( libsMapping.saveTo(mappingFile) logger.debug("Saved libs mapping to $mappingFile") } + + private fun setInfoPlistValues(plist: InfoPlistBuilder) { + check(currentOS == OS.MacOS) { "Current OS is not macOS: $currentOS" } + + plist[PlistKeys.LSMinimumSystemVersion] = "10.13" + plist[PlistKeys.CFBundleDevelopmentRegion] = "English" + plist[PlistKeys.CFBundleAllowMixedLocalizations] = "true" + val packageName = packageName.get() + plist[PlistKeys.CFBundleExecutable] = packageName + plist[PlistKeys.CFBundleIconFile] = "$packageName.icns" + val bundleId = nonValidatedMacBundleID.orNull + ?: launcherMainClass.get().substringBeforeLast(".") + plist[PlistKeys.CFBundleIdentifier] = bundleId + plist[PlistKeys.CFBundleInfoDictionaryVersion] = "6.0" + plist[PlistKeys.CFBundleName] = packageName + plist[PlistKeys.CFBundlePackageType] = "APPL" + val packageVersion = packageVersion.get()!! + plist[PlistKeys.CFBundleShortVersionString] = packageVersion + plist[PlistKeys.LSApplicationCategoryType] = "Unknown" + plist[PlistKeys.CFBundleVersion] = packageVersion + val year = Calendar.getInstance().get(Calendar.YEAR) + plist[PlistKeys.NSHumanReadableCopyright] = packageCopyright.orNull + ?: "Copyright (C) $year" + plist[PlistKeys.NSSupportsAutomaticGraphicsSwitching] = "true" + plist[PlistKeys.NSHighResolutionCapable] = "true" + } } // Serializable is only needed to avoid breaking configuration cache: diff --git a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/gradle/DesktopApplicationTest.kt b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/gradle/DesktopApplicationTest.kt index bc4ab77e61..c44a0309a7 100644 --- a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/gradle/DesktopApplicationTest.kt +++ b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/gradle/DesktopApplicationTest.kt @@ -13,7 +13,9 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assumptions import org.junit.jupiter.api.Test import java.io.File +import java.util.* import java.util.jar.JarFile +import kotlin.collections.HashSet class DesktopApplicationTest : GradlePluginTestBase() { @Test @@ -143,6 +145,12 @@ class DesktopApplicationTest : GradlePluginTestBase() { @Test fun testMacOptions() { + fun String.normalized(): String = + trim().replace( + "Copyright (C) ${Calendar.getInstance().get(Calendar.YEAR)}", + "Copyright (C) CURRENT_YEAR" + ) + Assumptions.assumeTrue(currentOS == OS.MacOS) with(testProject(TestProjects.macOptions)) { @@ -150,8 +158,11 @@ class DesktopApplicationTest : GradlePluginTestBase() { check.taskOutcome(":runDistributable", TaskOutcome.SUCCESS) check.logContains("Hello, from Mac OS!") val appDir = testWorkDir.resolve("build/compose/binaries/main/app/TestPackage.app/Contents/") - val infoPlist = appDir.resolve("Info.plist").checkExists().checkExists() - infoPlist.readText().checkContains("NSSupportsAutomaticGraphicsSwitching") + val actualInfoPlist = appDir.resolve("Info.plist").checkExists() + val expectedInfoPlist = testWorkDir.resolve("Expected-Info.Plist") + val actualInfoPlistNormalized = actualInfoPlist.readText().normalized() + val expectedInfoPlistNormalized = expectedInfoPlist.readText().normalized() + Assert.assertEquals(actualInfoPlistNormalized, expectedInfoPlistNormalized) } } } @@ -201,6 +212,11 @@ class DesktopApplicationTest : GradlePluginTestBase() { """.trimMargin().trim() Assert.assertEquals(expectedOutput, actualOutput) } + + gradle(":runDistributable").build().checks { check -> + check.taskOutcome(":runDistributable", TaskOutcome.SUCCESS) + check.logContains("Signed app successfully started!") + } } } } diff --git a/gradle-plugins/compose/src/test/test-projects/application/macOptions/Expected-Info.plist b/gradle-plugins/compose/src/test/test-projects/application/macOptions/Expected-Info.plist new file mode 100644 index 0000000000..1f914cb8b0 --- /dev/null +++ b/gradle-plugins/compose/src/test/test-projects/application/macOptions/Expected-Info.plist @@ -0,0 +1,36 @@ + + + + + LSMinimumSystemVersion + 10.13 + CFBundleDevelopmentRegion + English + CFBundleAllowMixedLocalizations + true + CFBundleExecutable + TestPackage + CFBundleIconFile + TestPackage.icns + CFBundleIdentifier + MainKt + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + TestPackage + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + LSApplicationCategoryType + Unknown + CFBundleVersion + 1.0.0 + NSHumanReadableCopyright + Copyright (C) CURRENT_YEAR + NSSupportsAutomaticGraphicsSwitching + true + NSHighResolutionCapable + true + +