Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package org.jetbrains.compose.desktop.application.dsl

import org.gradle.api.Action
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.model.ObjectFactory
import java.io.File
Expand Down Expand Up @@ -35,6 +36,7 @@ abstract class AbstractMacOSPlatformSettings : AbstractPlatformSettings() {
var dmgPackageBuildVersion: String? = null
var appCategory: String? = null
var minimumSystemVersion: String? = null
var layeredIconDir: DirectoryProperty = objects.directoryProperty()


/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ internal object PlistKeys {
val CFBundleTypeOSTypes by this
val CFBundleExecutable by this
val CFBundleIconFile by this
val CFBundleIconName by this
val CFBundleIdentifier by this
val CFBundleInfoDictionaryVersion by this
val CFBundleName by this
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package org.jetbrains.compose.desktop.application.internal

import org.gradle.api.logging.Logger
import org.jetbrains.compose.internal.utils.MacUtils
import java.io.File
import java.io.StringReader
import javax.xml.parsers.DocumentBuilderFactory

internal class MacAssetsTool(private val runTool: ExternalToolRunner, private val logger: Logger) {

fun compileAssets(iconDir: File, workingDir: File, minimumSystemVersion: String?): File {
val toolVersion = checkAssetsToolVersion()
logger.info("compile mac assets is starting, supported actool version:$toolVersion")

val result = runTool(
tool = MacUtils.xcrun,
args = listOf(
"actool",
iconDir.absolutePath, // Input asset catalog
"--compile", workingDir.absolutePath,
"--app-icon", iconDir.name.removeSuffix(".icon"),
"--enable-on-demand-resources", "NO",
"--development-region", "en",
"--target-device", "mac",
"--platform", "macosx",
"--enable-icon-stack-fallback-generation=disabled",
"--include-all-app-icons",
"--minimum-deployment-target", minimumSystemVersion ?: "10.13",
"--output-partial-info-plist", "/dev/null"
),
)

if (result.exitValue != 0) {
error("Could not compile the layered icons directory into Assets.car.")
}
if (!assetsFile(workingDir).exists()) {
error("Could not find Assets.car in the working directory.")
}
return workingDir.resolve("Assets.car")
}

fun assetsFile(workingDir: File): File = workingDir.resolve("Assets.car")

private fun checkAssetsToolVersion(): String {
val requiredVersion = 26.0
var outputContent = ""
val result = runTool(
tool = MacUtils.xcrun,
args = listOf("actool", "--version"),
processStdout = { outputContent = it },
)

if (result.exitValue != 0) {
error("Could not get actool version: Command `xcrun actool -version` exited with code ${result.exitValue}\nStdOut: $outputContent\n")
}

val versionString: String? = try {
val dbFactory = DocumentBuilderFactory.newInstance()
// Disable DTD loading to prevent XXE vulnerabilities and issues with network access or missing DTDs
dbFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-dtd-grammar", false)
dbFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
val dBuilder = dbFactory.newDocumentBuilder()
val xmlInput = org.xml.sax.InputSource(StringReader(outputContent))
val doc = dBuilder.parse(xmlInput)
doc.documentElement.normalize() // Recommended practice
val nodeList = doc.getElementsByTagName("key")
var version: String? = null
for (i in 0 until nodeList.length) {
if ("short-bundle-version" == nodeList.item(i).textContent) {
// Find the next sibling element which should be <string>
var nextSibling = nodeList.item(i).nextSibling
while (nextSibling != null && nextSibling.nodeType != org.w3c.dom.Node.ELEMENT_NODE) {
nextSibling = nextSibling.nextSibling
}
if (nextSibling != null && nextSibling.nodeName == "string") {
version = nextSibling.textContent
break
}
}
}
version
} catch (e: Exception) {
error("Could not parse actool version XML from output: '$outputContent'. Error: ${e.message}")
}

if (versionString == null) {
error("Could not extract short-bundle-version from actool output: '$outputContent'. Assuming it meets requirements.")
}

val majorVersion = versionString.split(".").firstOrNull()?.toIntOrNull()
if (majorVersion == null) {
error("Could not get actool major version from version string '$versionString' . Output was: '$outputContent'. Assuming it meets requirements.")
}

if (majorVersion < requiredVersion) {
error(
"Unsupported actool version: $versionString. " +
"Version $requiredVersion or higher is required. " +
"Please update your Xcode Command Line Tools."
)
} else {
return versionString
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ internal fun JvmApplicationContext.configurePlatformSettings(
packageTask.iconFile.set(mac.iconFile.orElse(defaultResources.get { macIcon }))
packageTask.installationPath.set(mac.installationPath)
packageTask.fileAssociations.set(provider { mac.fileAssociations })
packageTask.macLayeredIcons.set(mac.layeredIconDir)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ private fun configureNativeApplication(
}
composeResourcesDirs.setFrom(binaryResources)
}
macLayeredIcons.set(app.distributions.macOS.layeredIconDir)
}

if (TargetFormat.Dmg in app.distributions.targetFormats) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoP
import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistMapValue
import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistStringValue
import org.jetbrains.compose.desktop.application.internal.JvmRuntimeProperties
import org.jetbrains.compose.desktop.application.internal.MacAssetsTool
import org.jetbrains.compose.desktop.application.internal.MacSigner
import org.jetbrains.compose.desktop.application.internal.MacSignerImpl
import org.jetbrains.compose.desktop.application.internal.NoCertificateSigner
Expand Down Expand Up @@ -293,7 +294,11 @@ abstract class AbstractJPackageTask @Inject constructor(

@get:Input
internal val fileAssociations: SetProperty<FileAssociation> = objects.setProperty(FileAssociation::class.java)


@get:InputDirectory
@get:Optional
internal val macLayeredIcons: DirectoryProperty = objects.directoryProperty()

private val iconMapping by lazy {
val icons = fileAssociations.get().mapNotNull { it.iconFile }.distinct()
if (icons.isEmpty()) return@lazy emptyMap()
Expand Down Expand Up @@ -344,6 +349,8 @@ abstract class AbstractJPackageTask @Inject constructor(
} else null
}

private val macAssetsTool by lazy { MacAssetsTool(runExternalTool, logger) }

@get:LocalState
protected val signDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/sign")

Expand Down Expand Up @@ -600,12 +607,28 @@ abstract class AbstractJPackageTask @Inject constructor(

fileOperations.clearDirs(jpackageResources)
if (currentOS == OS.MacOS) {
val systemVersion = macMinimumSystemVersion.orNull ?: "10.13"

macLayeredIcons.ioFileOrNull?.let { layeredIcon ->
if (layeredIcon.exists()) {
runCatching {
macAssetsTool.compileAssets(
layeredIcon,
workingDir.ioFile,
systemVersion
)
}.onFailure {
logger.warn("Can not compile layered icon: ${it.message}")
}
}
}

InfoPlistBuilder(macExtraPlistKeysRawXml.orNull)
.also { setInfoPlistValues(it) }
.writeToFile(jpackageResources.ioFile.resolve("Info.plist"))

if (macAppStore.orNull == true) {
val systemVersion = macMinimumSystemVersion.orNull ?: "10.13"

val productDefPlistXml = """
<key>os</key>
<array>
Expand Down Expand Up @@ -642,6 +665,12 @@ abstract class AbstractJPackageTask @Inject constructor(
val appDir = destinationDir.ioFile.resolve("${packageName.get()}.app")
val runtimeDir = appDir.resolve("Contents/runtime")

macAssetsTool.assetsFile(workingDir.ioFile).apply {
if (exists()) {
copyTo(appDir.resolve("Contents/Resources/Assets.car"))
}
}

// Add the provisioning profile
macRuntimeProvisioningProfile.ioFileOrNull?.copyTo(
target = runtimeDir.resolve("Contents/embedded.provisionprofile"),
Expand Down Expand Up @@ -739,6 +768,10 @@ abstract class AbstractJPackageTask @Inject constructor(
)
}
}

if (macAssetsTool.assetsFile(workingDir.ioFile).exists()) {
macLayeredIcons.orNull?.let { plist[PlistKeys.CFBundleIconName] = it.asFile.name.removeSuffix(".icon") }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@
package org.jetbrains.compose.desktop.application.tasks

import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*
import org.gradle.api.tasks.Optional
import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder
import org.jetbrains.compose.desktop.application.internal.MacAssetsTool
import org.jetbrains.compose.desktop.application.internal.PlistKeys
import org.jetbrains.compose.internal.utils.ioFile
import org.jetbrains.compose.internal.utils.notNullProperty
import org.jetbrains.compose.internal.utils.nullableProperty
import java.io.File
import kotlin.getValue

private const val KOTLIN_NATIVE_MIN_SUPPORTED_MAC_OS = "10.13"

Expand Down Expand Up @@ -49,6 +52,12 @@ abstract class AbstractNativeMacApplicationPackageAppDirTask : AbstractNativeMac
@get:PathSensitive(PathSensitivity.ABSOLUTE)
val composeResourcesDirs: ConfigurableFileCollection = objects.fileCollection()

@get:InputDirectory
@get:Optional
internal val macLayeredIcons: DirectoryProperty = objects.directoryProperty()

private val macAssetsTool by lazy { MacAssetsTool(runExternalTool, logger) }

override fun createPackage(destinationDir: File, workingDir: File) {
val packageName = packageName.get()
val appDir = destinationDir.resolve("$packageName.app").apply { mkdirs() }
Expand All @@ -60,6 +69,18 @@ abstract class AbstractNativeMacApplicationPackageAppDirTask : AbstractNativeMac
executable.ioFile.copyTo(appExecutableFile)
appExecutableFile.setExecutable(true)

macLayeredIcons.orNull?.let {
runCatching {
macAssetsTool.compileAssets(
iconDir = it.asFile,
workingDir = workingDir,
minimumSystemVersion = minimumSystemVersion.getOrElse(KOTLIN_NATIVE_MIN_SUPPORTED_MAC_OS)
)
}.onFailure { error ->
logger.warn("Can not compile layered icon: ${error.message}")
}
}

val appIconFile = appResourcesDir.resolve("$packageName.icns")
iconFile.ioFile.copyTo(appIconFile)

Expand All @@ -74,6 +95,15 @@ abstract class AbstractNativeMacApplicationPackageAppDirTask : AbstractNativeMac
copySpec.into(appResourcesDir.resolve("compose-resources").apply { mkdirs() })
}
}

macAssetsTool.assetsFile(workingDir).let {
if (it.exists()) {
fileOperations.copy { copySpec ->
copySpec.from(it)
copySpec.into(appResourcesDir)
}
}
}
}

private fun InfoPlistBuilder.setupInfoPlist(executableName: String) {
Expand All @@ -90,5 +120,9 @@ abstract class AbstractNativeMacApplicationPackageAppDirTask : AbstractNativeMac
this[PlistKeys.NSHumanReadableCopyright] = copyright.orNull
this[PlistKeys.NSSupportsAutomaticGraphicsSwitching] = "true"
this[PlistKeys.NSHighResolutionCapable] = "true"

if (macAssetsTool.assetsFile(workingDir.ioFile).exists()) {
macLayeredIcons.orNull?.let { this[PlistKeys.CFBundleIconName] = it.asFile.name.removeSuffix(".icon") }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.jetbrains.compose.internal.utils.currentArch
import org.jetbrains.compose.internal.utils.currentOS
import org.jetbrains.compose.internal.utils.currentTarget
import org.jetbrains.compose.internal.utils.uppercaseFirstChar
import org.jetbrains.compose.test.utils.ChecksWrapper
import org.jetbrains.compose.test.utils.GradlePluginTestBase
import org.jetbrains.compose.test.utils.JDK_11_BYTECODE_VERSION
import org.jetbrains.compose.test.utils.ProcessRunResult
Expand Down Expand Up @@ -366,6 +367,77 @@ class DesktopApplicationTest : GradlePluginTestBase() {
}
}

@Test
fun testMacLayeredIcon() {
Assumptions.assumeTrue(currentOS == OS.MacOS)

with(testProject("application/macLayeredIcon")) {
val supportedString = "compile mac assets is starting, supported actool version:"

fun ChecksWrapper.checkContent(buildDir: String) {
if (check.log.contains(supportedString)) {
val targetAssetsFile = file("${buildDir}/Test Layered Icon.app/Contents/Resources/Assets.car")
targetAssetsFile.checkExists()
} else {
Assert.assertTrue(check.log.contains("Can not compile layered icon:"))
}
}

gradle(":packageDistributionForCurrentOS").checks {
checkContent(buildDir = "build/compose/binaries/main/app")
}
gradle(":createDistributableNativeDebugMacosX64").checks {
checkContent(buildDir = "build/compose/binaries/main/native-macosX64-debug-app-image")
}
}
}

@Test
fun testMacLayeredIconRemove() {
Assumptions.assumeTrue(currentOS == OS.MacOS)

with(testProject("application/macLayeredIcon")) {
val supportedString = "compile mac assets is starting, supported actool version:"
val unSupportedString = "Can not compile layered icon:"

fun ChecksWrapper.checkContent(buildDir: String, hasLayeredIcon: Boolean = true) {
if (hasLayeredIcon) {
if (check.log.contains(supportedString)) {
val targetAssetsFile =
file("${buildDir}/Test Layered Icon.app/Contents/Resources/Assets.car")
targetAssetsFile.checkExists()
} else {
Assert.assertTrue(check.log.contains(unSupportedString))
}
} else {
val targetAssetsFile =
file("${buildDir}/Test Layered Icon.app/Contents/Resources/Assets.car")
targetAssetsFile.checkNotExists()
Assert.assertTrue(!check.log.contains(supportedString) && !check.log.contains(unSupportedString))
}
}

gradle(":packageDistributionForCurrentOS").checks {
checkContent(buildDir = "build/compose/binaries/main/app")
}
gradle(":createDistributableNativeDebugMacosX64").checks {
checkContent(buildDir = "build/compose/binaries/main/native-macosX64-debug-app-image")
}

file("build.gradle.kts").modify {
it.replace("layeredIconDir.set(project.file(\"subdir/kotlin_icon_big.icon\"))", "")
}

gradle(":packageDistributionForCurrentOS").checks {
checkContent(buildDir = "build/compose/binaries/main/app", hasLayeredIcon = false)
}
gradle(":createDistributableNativeDebugMacosX64").checks {
checkContent(buildDir = "build/compose/binaries/main/native-macosX64-debug-app-image", hasLayeredIcon = false)
}

}
}

private fun macSignProject(
identity: String,
keychainFilename: String,
Expand Down
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading