Skip to content

Commit b608da2

Browse files
committed
feat: Add support for macOS layered icons
This commit introduces support for using macOS layered icons (`.icon` directory) for both JVM and native application packaging. - A new `layeredIconDir` property is added to the `compose.desktop.application.nativeDistributions.macOS` and `compose.nativeApplication.macOS` DSLs. - The `actool` from Xcode Command Line Tools is used to compile the `.icon` directory into an `Assets.car` file. - The `Assets.car` file is included in the `.app` bundle resources. - The `Info.plist` is updated with `CFBundleIconName` to reference the compiled asset. - A check for a supported `actool` version is performed before compilation. - New integration tests (`testMacLayeredIcon`, `testMacLayeredIconRemove`) are added to verify the functionality for both JVM and native targets.
1 parent 29bb377 commit b608da2

19 files changed

Lines changed: 560 additions & 2 deletions

File tree

gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package org.jetbrains.compose.desktop.application.dsl
77

88
import org.gradle.api.Action
9+
import org.gradle.api.file.DirectoryProperty
910
import org.gradle.api.file.RegularFileProperty
1011
import org.gradle.api.model.ObjectFactory
1112
import java.io.File
@@ -35,6 +36,7 @@ abstract class AbstractMacOSPlatformSettings : AbstractPlatformSettings() {
3536
var dmgPackageBuildVersion: String? = null
3637
var appCategory: String? = null
3738
var minimumSystemVersion: String? = null
39+
var layeredIconDir: DirectoryProperty = objects.directoryProperty()
3840

3941

4042
/**

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ internal object PlistKeys {
101101
val CFBundleTypeOSTypes by this
102102
val CFBundleExecutable by this
103103
val CFBundleIconFile by this
104+
val CFBundleIconName by this
104105
val CFBundleIdentifier by this
105106
val CFBundleInfoDictionaryVersion by this
106107
val CFBundleName by this
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package org.jetbrains.compose.desktop.application.internal
2+
3+
import org.gradle.api.logging.Logger
4+
import org.jetbrains.compose.internal.utils.MacUtils
5+
import java.io.File
6+
import java.io.StringReader
7+
import javax.xml.parsers.DocumentBuilderFactory
8+
9+
internal class MacAssetsTool(private val runTool: ExternalToolRunner, private val logger: Logger) {
10+
11+
fun compileAssets(iconDir: File, workingDir: File, minimumSystemVersion: String?): File {
12+
val toolVersion = checkAssetsToolVersion()
13+
logger.info("compile mac assets is starting, supported actool version:$toolVersion")
14+
15+
val result = runTool(
16+
tool = MacUtils.xcrun,
17+
args = listOf(
18+
"actool",
19+
iconDir.absolutePath, // Input asset catalog
20+
"--compile", workingDir.absolutePath,
21+
"--app-icon", iconDir.name.removeSuffix(".icon"),
22+
"--enable-on-demand-resources", "NO",
23+
"--development-region", "en",
24+
"--target-device", "mac",
25+
"--platform", "macosx",
26+
"--enable-icon-stack-fallback-generation=disabled",
27+
"--include-all-app-icons",
28+
"--minimum-deployment-target", minimumSystemVersion ?: "10.13",
29+
"--output-partial-info-plist", "/dev/null"
30+
),
31+
)
32+
33+
if (result.exitValue != 0) {
34+
error("Could not compile the layered icons directory into Assets.car.")
35+
}
36+
if (!assetsFile(workingDir).exists()) {
37+
error("Could not find Assets.car in the working directory.")
38+
}
39+
return workingDir.resolve("Assets.car")
40+
}
41+
42+
fun assetsFile(workingDir: File): File = workingDir.resolve("Assets.car")
43+
44+
private fun checkAssetsToolVersion(): String {
45+
val requiredVersion = 26.0
46+
var outputContent = ""
47+
val result = runTool(
48+
tool = MacUtils.xcrun,
49+
args = listOf("actool", "--version"),
50+
processStdout = { outputContent = it },
51+
)
52+
53+
if (result.exitValue != 0) {
54+
error("Could not get actool version: Command `xcrun actool -version` exited with code ${result.exitValue}\nStdOut: $outputContent\n")
55+
}
56+
57+
val versionString: String? = try {
58+
val dbFactory = DocumentBuilderFactory.newInstance()
59+
// Disable DTD loading to prevent XXE vulnerabilities and issues with network access or missing DTDs
60+
dbFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-dtd-grammar", false)
61+
dbFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
62+
val dBuilder = dbFactory.newDocumentBuilder()
63+
val xmlInput = org.xml.sax.InputSource(StringReader(outputContent))
64+
val doc = dBuilder.parse(xmlInput)
65+
doc.documentElement.normalize() // Recommended practice
66+
val nodeList = doc.getElementsByTagName("key")
67+
var version: String? = null
68+
for (i in 0 until nodeList.length) {
69+
if ("short-bundle-version" == nodeList.item(i).textContent) {
70+
// Find the next sibling element which should be <string>
71+
var nextSibling = nodeList.item(i).nextSibling
72+
while (nextSibling != null && nextSibling.nodeType != org.w3c.dom.Node.ELEMENT_NODE) {
73+
nextSibling = nextSibling.nextSibling
74+
}
75+
if (nextSibling != null && nextSibling.nodeName == "string") {
76+
version = nextSibling.textContent
77+
break
78+
}
79+
}
80+
}
81+
version
82+
} catch (e: Exception) {
83+
error("Could not parse actool version XML from output: '$outputContent'. Error: ${e.message}")
84+
}
85+
86+
if (versionString == null) {
87+
error("Could not extract short-bundle-version from actool output: '$outputContent'. Assuming it meets requirements.")
88+
}
89+
90+
val majorVersion = versionString.split(".").firstOrNull()?.toIntOrNull()
91+
if (majorVersion == null) {
92+
error("Could not get actool major version from version string '$versionString' . Output was: '$outputContent'. Assuming it meets requirements.")
93+
}
94+
95+
if (majorVersion < requiredVersion) {
96+
error(
97+
"Unsupported actool version: $versionString. " +
98+
"Version $requiredVersion or higher is required. " +
99+
"Please update your Xcode Command Line Tools."
100+
)
101+
} else {
102+
return versionString
103+
}
104+
}
105+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ internal fun JvmApplicationContext.configurePlatformSettings(
413413
packageTask.iconFile.set(mac.iconFile.orElse(defaultResources.get { macIcon }))
414414
packageTask.installationPath.set(mac.installationPath)
415415
packageTask.fileAssociations.set(provider { mac.fileAssociations })
416+
packageTask.macLayeredIcons.set(mac.layeredIconDir)
416417
}
417418
}
418419
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ private fun configureNativeApplication(
7676
}
7777
composeResourcesDirs.setFrom(binaryResources)
7878
}
79+
macLayeredIcons.set(app.distributions.macOS.layeredIconDir)
7980
}
8081

8182
if (TargetFormat.Dmg in app.distributions.targetFormats) {

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoP
3737
import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistMapValue
3838
import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistStringValue
3939
import org.jetbrains.compose.desktop.application.internal.JvmRuntimeProperties
40+
import org.jetbrains.compose.desktop.application.internal.MacAssetsTool
4041
import org.jetbrains.compose.desktop.application.internal.MacSigner
4142
import org.jetbrains.compose.desktop.application.internal.MacSignerImpl
4243
import org.jetbrains.compose.desktop.application.internal.NoCertificateSigner
@@ -293,7 +294,15 @@ abstract class AbstractJPackageTask @Inject constructor(
293294

294295
@get:Input
295296
internal val fileAssociations: SetProperty<FileAssociation> = objects.setProperty(FileAssociation::class.java)
296-
297+
298+
@get:InputDirectory
299+
@get:Optional
300+
internal val macLayeredIcons: DirectoryProperty = objects.directoryProperty()
301+
302+
@get:InputDirectory
303+
@get:Optional
304+
internal val macAssetsDir: DirectoryProperty = objects.directoryProperty()
305+
297306
private val iconMapping by lazy {
298307
val icons = fileAssociations.get().mapNotNull { it.iconFile }.distinct()
299308
if (icons.isEmpty()) return@lazy emptyMap()
@@ -344,6 +353,8 @@ abstract class AbstractJPackageTask @Inject constructor(
344353
} else null
345354
}
346355

356+
private val macAssetsTool by lazy { MacAssetsTool(runExternalTool, logger) }
357+
347358
@get:LocalState
348359
protected val signDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/sign")
349360

@@ -600,12 +611,28 @@ abstract class AbstractJPackageTask @Inject constructor(
600611

601612
fileOperations.clearDirs(jpackageResources)
602613
if (currentOS == OS.MacOS) {
614+
val systemVersion = macMinimumSystemVersion.orNull ?: "10.13"
615+
616+
macLayeredIcons.ioFileOrNull?.let { layeredIcon ->
617+
if (layeredIcon.exists()) {
618+
runCatching {
619+
macAssetsTool.compileAssets(
620+
layeredIcon,
621+
workingDir.ioFile,
622+
systemVersion
623+
)
624+
}.onFailure {
625+
logger.warn("Can not compile layered icon: ${it.message}")
626+
}
627+
}
628+
}
629+
603630
InfoPlistBuilder(macExtraPlistKeysRawXml.orNull)
604631
.also { setInfoPlistValues(it) }
605632
.writeToFile(jpackageResources.ioFile.resolve("Info.plist"))
606633

607634
if (macAppStore.orNull == true) {
608-
val systemVersion = macMinimumSystemVersion.orNull ?: "10.13"
635+
609636
val productDefPlistXml = """
610637
<key>os</key>
611638
<array>
@@ -642,6 +669,12 @@ abstract class AbstractJPackageTask @Inject constructor(
642669
val appDir = destinationDir.ioFile.resolve("${packageName.get()}.app")
643670
val runtimeDir = appDir.resolve("Contents/runtime")
644671

672+
macAssetsTool.assetsFile(workingDir.ioFile).apply {
673+
if (exists()) {
674+
copyTo(appDir.resolve("Contents/Resources/Assets.car"))
675+
}
676+
}
677+
645678
// Add the provisioning profile
646679
macRuntimeProvisioningProfile.ioFileOrNull?.copyTo(
647680
target = runtimeDir.resolve("Contents/embedded.provisionprofile"),
@@ -739,6 +772,10 @@ abstract class AbstractJPackageTask @Inject constructor(
739772
)
740773
}
741774
}
775+
776+
if (macAssetsTool.assetsFile(workingDir.ioFile).exists()) {
777+
macLayeredIcons.orNull?.let { plist[PlistKeys.CFBundleIconName] = it.asFile.name.removeSuffix(".icon") }
778+
}
742779
}
743780
}
744781

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@
66
package org.jetbrains.compose.desktop.application.tasks
77

88
import org.gradle.api.file.ConfigurableFileCollection
9+
import org.gradle.api.file.DirectoryProperty
910
import org.gradle.api.file.RegularFileProperty
1011
import org.gradle.api.provider.Property
1112
import org.gradle.api.tasks.*
1213
import org.gradle.api.tasks.Optional
1314
import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder
15+
import org.jetbrains.compose.desktop.application.internal.MacAssetsTool
1416
import org.jetbrains.compose.desktop.application.internal.PlistKeys
1517
import org.jetbrains.compose.internal.utils.ioFile
1618
import org.jetbrains.compose.internal.utils.notNullProperty
1719
import org.jetbrains.compose.internal.utils.nullableProperty
1820
import java.io.File
21+
import kotlin.getValue
1922

2023
private const val KOTLIN_NATIVE_MIN_SUPPORTED_MAC_OS = "10.13"
2124

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

55+
@get:InputDirectory
56+
@get:Optional
57+
internal val macLayeredIcons: DirectoryProperty = objects.directoryProperty()
58+
59+
private val macAssetsTool by lazy { MacAssetsTool(runExternalTool, logger) }
60+
5261
override fun createPackage(destinationDir: File, workingDir: File) {
5362
val packageName = packageName.get()
5463
val appDir = destinationDir.resolve("$packageName.app").apply { mkdirs() }
@@ -60,6 +69,18 @@ abstract class AbstractNativeMacApplicationPackageAppDirTask : AbstractNativeMac
6069
executable.ioFile.copyTo(appExecutableFile)
6170
appExecutableFile.setExecutable(true)
6271

72+
macLayeredIcons.orNull?.let {
73+
runCatching {
74+
macAssetsTool.compileAssets(
75+
iconDir = it.asFile,
76+
workingDir = workingDir,
77+
minimumSystemVersion = minimumSystemVersion.getOrElse(KOTLIN_NATIVE_MIN_SUPPORTED_MAC_OS)
78+
)
79+
}.onFailure { error ->
80+
logger.warn("Can not compile layered icon: ${error.message}")
81+
}
82+
}
83+
6384
val appIconFile = appResourcesDir.resolve("$packageName.icns")
6485
iconFile.ioFile.copyTo(appIconFile)
6586

@@ -74,6 +95,15 @@ abstract class AbstractNativeMacApplicationPackageAppDirTask : AbstractNativeMac
7495
copySpec.into(appResourcesDir.resolve("compose-resources").apply { mkdirs() })
7596
}
7697
}
98+
99+
macAssetsTool.assetsFile(workingDir).let {
100+
if (it.exists()) {
101+
fileOperations.copy { copySpec ->
102+
copySpec.from(it)
103+
copySpec.into(appResourcesDir)
104+
}
105+
}
106+
}
77107
}
78108

79109
private fun InfoPlistBuilder.setupInfoPlist(executableName: String) {
@@ -90,5 +120,9 @@ abstract class AbstractNativeMacApplicationPackageAppDirTask : AbstractNativeMac
90120
this[PlistKeys.NSHumanReadableCopyright] = copyright.orNull
91121
this[PlistKeys.NSSupportsAutomaticGraphicsSwitching] = "true"
92122
this[PlistKeys.NSHighResolutionCapable] = "true"
123+
124+
if (macAssetsTool.assetsFile(workingDir.ioFile).exists()) {
125+
macLayeredIcons.orNull?.let { this[PlistKeys.CFBundleIconName] = it.asFile.name.removeSuffix(".icon") }
126+
}
93127
}
94128
}

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import org.jetbrains.compose.internal.utils.currentArch
1212
import org.jetbrains.compose.internal.utils.currentOS
1313
import org.jetbrains.compose.internal.utils.currentTarget
1414
import org.jetbrains.compose.internal.utils.uppercaseFirstChar
15+
import org.jetbrains.compose.test.utils.ChecksWrapper
1516
import org.jetbrains.compose.test.utils.GradlePluginTestBase
1617
import org.jetbrains.compose.test.utils.JDK_11_BYTECODE_VERSION
1718
import org.jetbrains.compose.test.utils.ProcessRunResult
@@ -366,6 +367,77 @@ class DesktopApplicationTest : GradlePluginTestBase() {
366367
}
367368
}
368369

370+
@Test
371+
fun testMacLayeredIcon() {
372+
Assumptions.assumeTrue(currentOS == OS.MacOS)
373+
374+
with(testProject("application/macLayeredIcon")) {
375+
val supportedString = "compile mac assets is starting, supported actool version:"
376+
377+
fun ChecksWrapper.checkContent(buildDir: String) {
378+
if (check.log.contains(supportedString)) {
379+
val targetAssetsFile = file("${buildDir}/Test Layered Icon.app/Contents/Resources/Assets.car")
380+
targetAssetsFile.checkExists()
381+
} else {
382+
Assert.assertTrue(check.log.contains("Can not compile layered icon:"))
383+
}
384+
}
385+
386+
gradle(":packageDistributionForCurrentOS").checks {
387+
checkContent(buildDir = "build/compose/binaries/main/app")
388+
}
389+
gradle(":createDistributableNativeDebugMacosX64").checks {
390+
checkContent(buildDir = "build/compose/binaries/main/native-macosX64-debug-app-image")
391+
}
392+
}
393+
}
394+
395+
@Test
396+
fun testMacLayeredIconRemove() {
397+
Assumptions.assumeTrue(currentOS == OS.MacOS)
398+
399+
with(testProject("application/macLayeredIcon")) {
400+
val supportedString = "compile mac assets is starting, supported actool version:"
401+
val unSupportedString = "Can not compile layered icon:"
402+
403+
fun ChecksWrapper.checkContent(buildDir: String, hasLayeredIcon: Boolean = true) {
404+
if (hasLayeredIcon) {
405+
if (check.log.contains(supportedString)) {
406+
val targetAssetsFile =
407+
file("${buildDir}/Test Layered Icon.app/Contents/Resources/Assets.car")
408+
targetAssetsFile.checkExists()
409+
} else {
410+
Assert.assertTrue(check.log.contains(unSupportedString))
411+
}
412+
} else {
413+
val targetAssetsFile =
414+
file("${buildDir}/Test Layered Icon.app/Contents/Resources/Assets.car")
415+
targetAssetsFile.checkNotExists()
416+
Assert.assertTrue(!check.log.contains(supportedString) && !check.log.contains(unSupportedString))
417+
}
418+
}
419+
420+
gradle(":packageDistributionForCurrentOS").checks {
421+
checkContent(buildDir = "build/compose/binaries/main/app")
422+
}
423+
gradle(":createDistributableNativeDebugMacosX64").checks {
424+
checkContent(buildDir = "build/compose/binaries/main/native-macosX64-debug-app-image")
425+
}
426+
427+
file("build.gradle.kts").modify {
428+
it.replace("layeredIconDir.set(project.file(\"subdir/kotlin_icon_big.icon\"))", "")
429+
}
430+
431+
gradle(":packageDistributionForCurrentOS").checks {
432+
checkContent(buildDir = "build/compose/binaries/main/app", hasLayeredIcon = false)
433+
}
434+
gradle(":createDistributableNativeDebugMacosX64").checks {
435+
checkContent(buildDir = "build/compose/binaries/main/native-macosX64-debug-app-image", hasLayeredIcon = false)
436+
}
437+
438+
}
439+
}
440+
369441
private fun macSignProject(
370442
identity: String,
371443
keychainFilename: String,

0 commit comments

Comments
 (0)