Skip to content
Merged
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
88 changes: 85 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,78 @@ Implementation details:

---

### 11. macOS Layered Icons (macOS 26+)

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.

```kotlin
nativeDistributions {
macOS {
layeredIconDir.set(project.file("icons/MyApp.icon"))
}
}
```

**How it works:**
1. At packaging time, `xcrun actool` compiles the `.icon` directory into an `Assets.car` file.
2. The `Assets.car` is placed inside `<AppName>.app/Contents/Resources/`.
3. The `Info.plist` is updated with a `CFBundleIconName` entry referencing the compiled asset.
4. The traditional `.icns` icon (`iconFile`) is still used as a fallback for older macOS versions, so you should keep both.

**Creating a `.icon` directory:**

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**:

1. Open Xcode and create a new Asset Catalog (or use an existing one).
2. Add a new **App Icon** asset.
3. Configure the layers (front, back, etc.) with your images.
4. Export the `.icon` directory from the asset catalog.

A minimal `.icon` directory structure looks like:

```
MyApp.icon/
icon.json
Assets/
MyImage.png
```

**Requirements:**
- **Xcode Command Line Tools** with `actool` version **26.0 or higher** (ships with Xcode 26+).
- Only effective on **macOS** build hosts. On other platforms the property is ignored.
- If `actool` is missing or too old, a warning is logged and the build continues without layered icon support.

**Full example with both icons:**

```kotlin
nativeDistributions {
macOS {
// Traditional icon (required fallback for older macOS)
iconFile.set(project.file("icons/MyApp.icns"))

// Layered icon for macOS 26+ dynamic effects
layeredIconDir.set(project.file("icons/MyApp.icon"))
}
}
```

**Native Kotlin/Native application:**

Layered icons also work with `nativeApplication` targets:

```kotlin
composeDeskKit.desktop.nativeApplication {
distributions {
macOS {
iconFile.set(project.file("icons/MyApp.icns"))
layeredIconDir.set(project.file("icons/MyApp.icon"))
}
}
}
```

---

## Full DSL Reference (new properties only)

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

### `nativeDistributions { macOS { ... } }`

| Property | Type | Default | Description |
|---|---|---|---|
| `layeredIconDir` | `DirectoryProperty` | unset | Path to a `.icon` directory for macOS 26+ layered icons |

### `nativeDistributions { windows { ... } }`

| Property | Type | Default | Description |
Expand Down Expand Up @@ -382,6 +460,11 @@ composeDeskKit.desktop.application {
rpmCompressionLevel = 19
}

macOS {
iconFile.set(project.file("icons/MyApp.icns"))
layeredIconDir.set(project.file("icons/MyApp.icon"))
}

windows {
msix {
identityName = "MyCompany.MyApp"
Expand All @@ -398,11 +481,10 @@ composeDeskKit.desktop.application {

## Migration from `org.jetbrains.compose`

1. Replace the plugin ID:
1. Add the plugin ID:
```diff
- id("org.jetbrains.compose") version "x.y.z"
+ id("io.github.kdroidfilter.composedeskkit") version "1.0.0"
```
```

2. Replace the DSL extension name:
```diff
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package io.github.kdroidfilter.composedeskkit.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 @@ -40,6 +41,7 @@ abstract class AbstractMacOSPlatformSettings : AbstractPlatformSettings() {
var dmgPackageBuildVersion: String? = null
var appCategory: String? = null
var minimumSystemVersion: String? = null
var layeredIconDir: DirectoryProperty = objects.directoryProperty()

/**
* An application's unique identifier across Apple's ecosystem.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,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,99 @@
package io.github.kdroidfilter.composedeskkit.desktop.application.internal

import org.gradle.api.logging.Logger
import io.github.kdroidfilter.composedeskkit.internal.utils.MacUtils
import java.io.File

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 {
var versionContent = ""
runTool(
tool = MacUtils.plutil,
args = listOf(
"-extract",
"com\\.apple\\.actool\\.version.short-bundle-version",
"raw",
"-expect",
"string",
"-o",
"-",
"-"
),
stdinStr = outputContent,
processStdout = {
versionContent = it
}
)
versionContent
} catch (e: Exception) {
error("Could not check actool version. Error: ${e.message}")
}

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

val majorVersion = versionString
.split(".")
.firstOrNull()
?.toIntOrNull()
?: 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 @@ -571,6 +571,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 @@ -80,6 +80,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 @@ -16,6 +16,7 @@ import io.github.kdroidfilter.composedeskkit.desktop.application.internal.InfoPl
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistMapValue
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistStringValue
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.JvmRuntimeProperties
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.MacAssetsTool
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.LinuxPackagePostProcessor
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.MacSigner
import io.github.kdroidfilter.composedeskkit.desktop.application.internal.MacSignerImpl
Expand Down Expand Up @@ -329,6 +330,10 @@ abstract class AbstractJPackageTask
@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 @@ -384,6 +389,8 @@ abstract class AbstractJPackageTask
}
}

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 @@ -658,12 +665,28 @@ abstract class AbstractJPackageTask

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

macLayeredIcons.ioFileOrNull?.let { layeredIcon ->
if (layeredIcon.exists()) {
try {
macAssetsTool.compileAssets(
layeredIcon,
workingDir.ioFile,
systemVersion
)
} catch (e: Exception) {
logger.warn("Can not compile layered icon: ${e.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>
Expand Down Expand Up @@ -765,6 +788,12 @@ abstract class AbstractJPackageTask
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 @@ -865,6 +894,10 @@ abstract class AbstractJPackageTask
)
}
}

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

Expand Down
Loading
Loading