diff --git a/README.md b/README.md index 7ea21b59..c61c9799 100644 --- a/README.md +++ b/README.md @@ -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 `.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 { ... }` @@ -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 | @@ -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" @@ -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 diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/dsl/PlatformSettings.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/dsl/PlatformSettings.kt index d84e2c19..437a0eed 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/dsl/PlatformSettings.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/dsl/PlatformSettings.kt @@ -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 @@ -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. diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/InfoPlistBuilder.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/InfoPlistBuilder.kt index 7958efe1..f34d9fdc 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/InfoPlistBuilder.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/InfoPlistBuilder.kt @@ -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 diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/MacAssetsTool.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/MacAssetsTool.kt new file mode 100644 index 00000000..3e081724 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/MacAssetsTool.kt @@ -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 + } + } +} diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/configureJvmApplication.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/configureJvmApplication.kt index a4dc575e..c503dbd4 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/configureJvmApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/configureJvmApplication.kt @@ -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) } } } diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/configureNativeApplication.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/configureNativeApplication.kt index 2a12661d..b8d2af93 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/configureNativeApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/internal/configureNativeApplication.kt @@ -80,6 +80,7 @@ private fun configureNativeApplication( } composeResourcesDirs.setFrom(binaryResources) } + macLayeredIcons.set(app.distributions.macOS.layeredIconDir) } if (TargetFormat.Dmg in app.distributions.targetFormats) { diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/tasks/AbstractJPackageTask.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/tasks/AbstractJPackageTask.kt index 22ab08c0..1f57fd59 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/tasks/AbstractJPackageTask.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/tasks/AbstractJPackageTask.kt @@ -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 @@ -329,6 +330,10 @@ abstract class AbstractJPackageTask @get:Input internal val fileAssociations: SetProperty = 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() @@ -384,6 +389,8 @@ abstract class AbstractJPackageTask } } + private val macAssetsTool by lazy { MacAssetsTool(runExternalTool, logger) } + @get:LocalState protected val signDir: Provider = project.layout.buildDirectory.dir("compose/tmp/sign") @@ -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 = """ os @@ -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"), @@ -865,6 +894,10 @@ abstract class AbstractJPackageTask ) } } + + if (macAssetsTool.assetsFile(workingDir.ioFile).exists()) { + macLayeredIcons.orNull?.let { plist[PlistKeys.CFBundleIconName] = it.asFile.name.removeSuffix(".icon") } + } } } diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/tasks/AbstractNativeMacApplicationPackageAppDirTask.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/tasks/AbstractNativeMacApplicationPackageAppDirTask.kt index c3e95fc1..8cdcf5d9 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/tasks/AbstractNativeMacApplicationPackageAppDirTask.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/desktop/application/tasks/AbstractNativeMacApplicationPackageAppDirTask.kt @@ -6,16 +6,19 @@ package io.github.kdroidfilter.composedeskkit.desktop.application.tasks import io.github.kdroidfilter.composedeskkit.desktop.application.internal.InfoPlistBuilder +import io.github.kdroidfilter.composedeskkit.desktop.application.internal.MacAssetsTool import io.github.kdroidfilter.composedeskkit.desktop.application.internal.PlistKeys import io.github.kdroidfilter.composedeskkit.internal.utils.ioFile import io.github.kdroidfilter.composedeskkit.internal.utils.notNullProperty import io.github.kdroidfilter.composedeskkit.internal.utils.nullableProperty 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 java.io.File +import kotlin.getValue private const val KOTLIN_NATIVE_MIN_SUPPORTED_MAC_OS = "10.13" @@ -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, @@ -63,6 +72,18 @@ abstract class AbstractNativeMacApplicationPackageAppDirTask : AbstractNativeMac executable.ioFile.copyTo(appExecutableFile) appExecutableFile.setExecutable(true) + macLayeredIcons.orNull?.let { + try { + macAssetsTool.compileAssets( + iconDir = it.asFile, + workingDir = workingDir, + minimumSystemVersion = minimumSystemVersion.getOrElse(KOTLIN_NATIVE_MIN_SUPPORTED_MAC_OS) + ) + } catch (e: Exception) { + logger.warn("Can not compile layered icon: ${e.message}") + } + } + val appIconFile = appResourcesDir.resolve("$packageName.icns") iconFile.ioFile.copyTo(appIconFile) @@ -77,6 +98,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) { @@ -93,5 +123,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") } + } } } diff --git a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/internal/utils/osUtils.kt b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/internal/utils/osUtils.kt index 059834a2..b65e76f3 100644 --- a/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/internal/utils/osUtils.kt +++ b/plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/composedeskkit/internal/utils/osUtils.kt @@ -85,6 +85,10 @@ internal object MacUtils { val open: File by lazy { File("/usr/bin/open").checkExistingFile() } + + val plutil: File by lazy { + File("/usr/bin/plutil").checkExistingFile() + } } internal object UnixUtils {