Skip to content

Commit 42d0cff

Browse files
authored
Merge pull request #4 from kdroidFilter/feat/macos-layered-icons
Add support for macOS 26+ layered icons
2 parents cf679bc + 5f96d29 commit 42d0cff

9 files changed

Lines changed: 261 additions & 4 deletions

File tree

README.md

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,78 @@ Implementation details:
299299

300300
---
301301

302+
### 11. macOS Layered Icons (macOS 26+)
303+
304+
Adds support for [macOS layered icons](https://developer.apple.com/design/human-interface-guidelines/app-icons#macOS) (`.icon` directory) introduced in macOS 26. Layered icons enable the dynamic tilt/depth effects shown on the Dock and in Spotlight.
305+
306+
```kotlin
307+
nativeDistributions {
308+
macOS {
309+
layeredIconDir.set(project.file("icons/MyApp.icon"))
310+
}
311+
}
312+
```
313+
314+
**How it works:**
315+
1. At packaging time, `xcrun actool` compiles the `.icon` directory into an `Assets.car` file.
316+
2. The `Assets.car` is placed inside `<AppName>.app/Contents/Resources/`.
317+
3. The `Info.plist` is updated with a `CFBundleIconName` entry referencing the compiled asset.
318+
4. The traditional `.icns` icon (`iconFile`) is still used as a fallback for older macOS versions, so you should keep both.
319+
320+
**Creating a `.icon` directory:**
321+
322+
A `.icon` directory is a folder with the `.icon` extension that contains an `icon.json` manifest and image assets. The easiest way to create one is with **Xcode 26+** or **Apple Icon Composer**:
323+
324+
1. Open Xcode and create a new Asset Catalog (or use an existing one).
325+
2. Add a new **App Icon** asset.
326+
3. Configure the layers (front, back, etc.) with your images.
327+
4. Export the `.icon` directory from the asset catalog.
328+
329+
A minimal `.icon` directory structure looks like:
330+
331+
```
332+
MyApp.icon/
333+
icon.json
334+
Assets/
335+
MyImage.png
336+
```
337+
338+
**Requirements:**
339+
- **Xcode Command Line Tools** with `actool` version **26.0 or higher** (ships with Xcode 26+).
340+
- Only effective on **macOS** build hosts. On other platforms the property is ignored.
341+
- If `actool` is missing or too old, a warning is logged and the build continues without layered icon support.
342+
343+
**Full example with both icons:**
344+
345+
```kotlin
346+
nativeDistributions {
347+
macOS {
348+
// Traditional icon (required fallback for older macOS)
349+
iconFile.set(project.file("icons/MyApp.icns"))
350+
351+
// Layered icon for macOS 26+ dynamic effects
352+
layeredIconDir.set(project.file("icons/MyApp.icon"))
353+
}
354+
}
355+
```
356+
357+
**Native Kotlin/Native application:**
358+
359+
Layered icons also work with `nativeApplication` targets:
360+
361+
```kotlin
362+
composeDeskKit.desktop.nativeApplication {
363+
distributions {
364+
macOS {
365+
iconFile.set(project.file("icons/MyApp.icns"))
366+
layeredIconDir.set(project.file("icons/MyApp.icon"))
367+
}
368+
}
369+
}
370+
```
371+
372+
---
373+
302374
## Full DSL Reference (new properties only)
303375

304376
### `nativeDistributions { ... }`
@@ -322,6 +394,12 @@ Implementation details:
322394
| `rpmCompression` | `RpmCompression?` | `null` | `.rpm` compression algorithm |
323395
| `rpmCompressionLevel` | `Int?` | `null` | `.rpm` compression level |
324396

397+
### `nativeDistributions { macOS { ... } }`
398+
399+
| Property | Type | Default | Description |
400+
|---|---|---|---|
401+
| `layeredIconDir` | `DirectoryProperty` | unset | Path to a `.icon` directory for macOS 26+ layered icons |
402+
325403
### `nativeDistributions { windows { ... } }`
326404

327405
| Property | Type | Default | Description |
@@ -382,6 +460,11 @@ composeDeskKit.desktop.application {
382460
rpmCompressionLevel = 19
383461
}
384462

463+
macOS {
464+
iconFile.set(project.file("icons/MyApp.icns"))
465+
layeredIconDir.set(project.file("icons/MyApp.icon"))
466+
}
467+
385468
windows {
386469
msix {
387470
identityName = "MyCompany.MyApp"
@@ -398,11 +481,10 @@ composeDeskKit.desktop.application {
398481

399482
## Migration from `org.jetbrains.compose`
400483

401-
1. Replace the plugin ID:
484+
1. Add the plugin ID:
402485
```diff
403-
- id("org.jetbrains.compose") version "x.y.z"
404486
+ id("io.github.kdroidfilter.composedeskkit") version "1.0.0"
405-
```
487+
```
406488

407489
2. Replace the DSL extension name:
408490
```diff

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/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 io.github.kdroidfilter.composedeskkit.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
@@ -40,6 +41,7 @@ abstract class AbstractMacOSPlatformSettings : AbstractPlatformSettings() {
4041
var dmgPackageBuildVersion: String? = null
4142
var appCategory: String? = null
4243
var minimumSystemVersion: String? = null
44+
var layeredIconDir: DirectoryProperty = objects.directoryProperty()
4345

4446
/**
4547
* An application's unique identifier across Apple's ecosystem.

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/InfoPlistBuilder.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ internal object PlistKeys {
142142
val CFBundleTypeOSTypes by this
143143
val CFBundleExecutable by this
144144
val CFBundleIconFile by this
145+
val CFBundleIconName by this
145146
val CFBundleIdentifier by this
146147
val CFBundleInfoDictionaryVersion by this
147148
val CFBundleName by this
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package io.github.kdroidfilter.composedeskkit.desktop.application.internal
2+
3+
import org.gradle.api.logging.Logger
4+
import io.github.kdroidfilter.composedeskkit.internal.utils.MacUtils
5+
import java.io.File
6+
7+
internal class MacAssetsTool(private val runTool: ExternalToolRunner, private val logger: Logger) {
8+
9+
fun compileAssets(iconDir: File, workingDir: File, minimumSystemVersion: String?): File {
10+
val toolVersion = checkAssetsToolVersion()
11+
logger.info("compile mac assets is starting, supported actool version:$toolVersion")
12+
13+
val result = runTool(
14+
tool = MacUtils.xcrun,
15+
args = listOf(
16+
"actool",
17+
iconDir.absolutePath, // Input asset catalog
18+
"--compile", workingDir.absolutePath,
19+
"--app-icon", iconDir.name.removeSuffix(".icon"),
20+
"--enable-on-demand-resources", "NO",
21+
"--development-region", "en",
22+
"--target-device", "mac",
23+
"--platform", "macosx",
24+
"--enable-icon-stack-fallback-generation=disabled",
25+
"--include-all-app-icons",
26+
"--minimum-deployment-target", minimumSystemVersion ?: "10.13",
27+
"--output-partial-info-plist", "/dev/null"
28+
),
29+
)
30+
31+
if (result.exitValue != 0) {
32+
error("Could not compile the layered icons directory into Assets.car.")
33+
}
34+
if (!assetsFile(workingDir).exists()) {
35+
error("Could not find Assets.car in the working directory.")
36+
}
37+
return workingDir.resolve("Assets.car")
38+
}
39+
40+
fun assetsFile(workingDir: File): File = workingDir.resolve("Assets.car")
41+
42+
private fun checkAssetsToolVersion(): String {
43+
val requiredVersion = 26.0
44+
var outputContent = ""
45+
val result = runTool(
46+
tool = MacUtils.xcrun,
47+
args = listOf("actool", "--version"),
48+
processStdout = { outputContent = it },
49+
)
50+
51+
if (result.exitValue != 0) {
52+
error("Could not get actool version: Command `xcrun actool -version` exited with code ${result.exitValue}\nStdOut: $outputContent\n")
53+
}
54+
55+
val versionString: String? = try {
56+
var versionContent = ""
57+
runTool(
58+
tool = MacUtils.plutil,
59+
args = listOf(
60+
"-extract",
61+
"com\\.apple\\.actool\\.version.short-bundle-version",
62+
"raw",
63+
"-expect",
64+
"string",
65+
"-o",
66+
"-",
67+
"-"
68+
),
69+
stdinStr = outputContent,
70+
processStdout = {
71+
versionContent = it
72+
}
73+
)
74+
versionContent
75+
} catch (e: Exception) {
76+
error("Could not check actool version. Error: ${e.message}")
77+
}
78+
79+
if (versionString.isNullOrBlank()) {
80+
error("Could not extract short-bundle-version from actool output: '$outputContent'. Assuming it meets requirements.")
81+
}
82+
83+
val majorVersion = versionString
84+
.split(".")
85+
.firstOrNull()
86+
?.toIntOrNull()
87+
?: error("Could not get actool major version from version string '$versionString' . Output was: '$outputContent'. Assuming it meets requirements.")
88+
89+
if (majorVersion < requiredVersion) {
90+
error(
91+
"Unsupported actool version: $versionString. " +
92+
"Version $requiredVersion or higher is required. " +
93+
"Please update your Xcode Command Line Tools."
94+
)
95+
} else {
96+
return versionString
97+
}
98+
}
99+
}

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/configureJvmApplication.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,7 @@ internal fun JvmApplicationContext.configurePlatformSettings(
571571
packageTask.iconFile.set(mac.iconFile.orElse(defaultResources.get { macIcon }))
572572
packageTask.installationPath.set(mac.installationPath)
573573
packageTask.fileAssociations.set(provider { mac.fileAssociations })
574+
packageTask.macLayeredIcons.set(mac.layeredIconDir)
574575
}
575576
}
576577
}

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/configureNativeApplication.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ private fun configureNativeApplication(
8080
}
8181
composeResourcesDirs.setFrom(binaryResources)
8282
}
83+
macLayeredIcons.set(app.distributions.macOS.layeredIconDir)
8384
}
8485

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

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/tasks/AbstractJPackageTask.kt

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import io.github.kdroidfilter.composedeskkit.desktop.application.internal.InfoPl
1616
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistMapValue
1717
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistStringValue
1818
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.JvmRuntimeProperties
19+
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.MacAssetsTool
1920
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.LinuxPackagePostProcessor
2021
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.MacSigner
2122
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.MacSignerImpl
@@ -329,6 +330,10 @@ abstract class AbstractJPackageTask
329330
@get:Input
330331
internal val fileAssociations: SetProperty<FileAssociation> = objects.setProperty(FileAssociation::class.java)
331332

333+
@get:InputDirectory
334+
@get:Optional
335+
internal val macLayeredIcons: DirectoryProperty = objects.directoryProperty()
336+
332337
private val iconMapping by lazy {
333338
val icons = fileAssociations.get().mapNotNull { it.iconFile }.distinct()
334339
if (icons.isEmpty()) return@lazy emptyMap()
@@ -384,6 +389,8 @@ abstract class AbstractJPackageTask
384389
}
385390
}
386391

392+
private val macAssetsTool by lazy { MacAssetsTool(runExternalTool, logger) }
393+
387394
@get:LocalState
388395
protected val signDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/sign")
389396

@@ -658,12 +665,28 @@ abstract class AbstractJPackageTask
658665

659666
fileOperations.clearDirs(jpackageResources)
660667
if (currentOS == OS.MacOS) {
668+
val systemVersion = macMinimumSystemVersion.orNull ?: "10.13"
669+
670+
macLayeredIcons.ioFileOrNull?.let { layeredIcon ->
671+
if (layeredIcon.exists()) {
672+
try {
673+
macAssetsTool.compileAssets(
674+
layeredIcon,
675+
workingDir.ioFile,
676+
systemVersion
677+
)
678+
} catch (e: Exception) {
679+
logger.warn("Can not compile layered icon: ${e.message}")
680+
}
681+
}
682+
}
683+
661684
InfoPlistBuilder(macExtraPlistKeysRawXml.orNull)
662685
.also { setInfoPlistValues(it) }
663686
.writeToFile(jpackageResources.ioFile.resolve("Info.plist"))
664687

665688
if (macAppStore.orNull == true) {
666-
val systemVersion = macMinimumSystemVersion.orNull ?: "10.13"
689+
667690
val productDefPlistXml =
668691
"""
669692
<key>os</key>
@@ -765,6 +788,12 @@ abstract class AbstractJPackageTask
765788
val appDir = destinationDir.ioFile.resolve("${packageName.get()}.app")
766789
val runtimeDir = appDir.resolve("Contents/runtime")
767790

791+
macAssetsTool.assetsFile(workingDir.ioFile).apply {
792+
if (exists()) {
793+
copyTo(appDir.resolve("Contents/Resources/Assets.car"))
794+
}
795+
}
796+
768797
// Add the provisioning profile
769798
macRuntimeProvisioningProfile.ioFileOrNull?.copyTo(
770799
target = runtimeDir.resolve("Contents/embedded.provisionprofile"),
@@ -865,6 +894,10 @@ abstract class AbstractJPackageTask
865894
)
866895
}
867896
}
897+
898+
if (macAssetsTool.assetsFile(workingDir.ioFile).exists()) {
899+
macLayeredIcons.orNull?.let { plist[PlistKeys.CFBundleIconName] = it.asFile.name.removeSuffix(".icon") }
900+
}
868901
}
869902
}
870903

0 commit comments

Comments
 (0)