diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/FileAssociation.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/FileAssociation.kt new file mode 100644 index 0000000000..a9e2bbff22 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/FileAssociation.kt @@ -0,0 +1,11 @@ +package org.jetbrains.compose.desktop.application.dsl + +import java.io.File +import java.io.Serializable + +internal data class FileAssociation( + val mimeType: String, + val extension: String, + val description: String, + val iconFile: File?, +) : Serializable diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/JvmApplicationDistributions.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/JvmApplicationDistributions.kt index e9fb935df6..f59b838b6a 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/JvmApplicationDistributions.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/JvmApplicationDistributions.kt @@ -6,6 +6,7 @@ package org.jetbrains.compose.desktop.application.dsl import org.gradle.api.Action +import java.io.File internal val DEFAULT_RUNTIME_MODULES = arrayOf( "java.base", "java.desktop", "java.logging", "jdk.crypto.ec" @@ -32,4 +33,14 @@ abstract class JvmApplicationDistributions : AbstractDistributions() { fun windows(fn: Action) { fn.execute(windows) } + + @JvmOverloads + fun fileAssociation( + mimeType: String, extension: String, description: String, + linuxIconFile: File? = null, windowsIconFile: File? = null, macOSIconFile: File? = null, + ) { + linux.fileAssociation(mimeType, extension, description, linuxIconFile) + windows.fileAssociation(mimeType, extension, description, windowsIconFile) + macOS.fileAssociation(mimeType, extension, description, macOSIconFile) + } } \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt index f8b3e3450b..d70ca65adf 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt @@ -8,6 +8,7 @@ package org.jetbrains.compose.desktop.application.dsl import org.gradle.api.Action import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory +import java.io.File import javax.inject.Inject abstract class AbstractPlatformSettings { @@ -17,6 +18,13 @@ abstract class AbstractPlatformSettings { val iconFile: RegularFileProperty = objects.fileProperty() var packageVersion: String? = null var installationPath: String? = null + + internal val fileAssociations: MutableSet = mutableSetOf() + + @JvmOverloads + fun fileAssociation(mimeType: String, extension: String, description: String, iconFile: File? = null) { + fileAssociations.add(FileAssociation(mimeType, extension, description, iconFile)) + } } abstract class AbstractMacOSPlatformSettings : AbstractPlatformSettings() { diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/InfoPlistBuilder.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/InfoPlistBuilder.kt index e349305730..aca1d8e858 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/InfoPlistBuilder.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/InfoPlistBuilder.kt @@ -5,14 +5,58 @@ package org.jetbrains.compose.desktop.application.internal +import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.* import java.io.File import kotlin.reflect.KProperty +private const val indent = " " +private fun indentForLevel(level: Int) = indent.repeat(level) + internal class InfoPlistBuilder(private val extraPlistKeysRawXml: String? = null) { - private val values = LinkedHashMap() + internal sealed class InfoPlistValue { + abstract fun asPlistEntry(nestingLevel: Int): String + data class InfoPlistListValue(val elements: List) : InfoPlistValue() { + override fun asPlistEntry(nestingLevel: Int): String = + if (elements.isEmpty()) "${indentForLevel(nestingLevel)}" + else elements.joinToString( + separator = "\n", + prefix = "${indentForLevel(nestingLevel)}\n", + postfix = "\n${indentForLevel(nestingLevel)}" + ) { + it.asPlistEntry(nestingLevel + 1) + } + + constructor(vararg elements: InfoPlistValue) : this(elements.asList()) + } + + data class InfoPlistMapValue(val elements: Map) : InfoPlistValue() { + override fun asPlistEntry(nestingLevel: Int): String = + if (elements.isEmpty()) "${indentForLevel(nestingLevel)}" + else elements.entries.joinToString( + separator = "\n", + prefix = "${indentForLevel(nestingLevel)}\n", + postfix = "\n${indentForLevel(nestingLevel)}", + ) { (key, value) -> + "${indentForLevel(nestingLevel + 1)}${key.name}\n${value.asPlistEntry(nestingLevel + 1)}" + } + + constructor(vararg elements: Pair) : this(elements.toMap()) + } + + data class InfoPlistStringValue(val value: String) : InfoPlistValue() { + override fun asPlistEntry(nestingLevel: Int): String = if (value.isEmpty()) "${indentForLevel(nestingLevel)}" else "${indentForLevel(nestingLevel)}$value" + } + } + + private val values = LinkedHashMap() + + operator fun get(key: InfoPlistKey): InfoPlistValue? = values[key] + operator fun set(key: InfoPlistKey, value: String?) = set(key, value?.let(::InfoPlistStringValue)) + operator fun set(key: InfoPlistKey, value: List?) = set(key, value?.let(::InfoPlistListValue)) + operator fun set(key: InfoPlistKey, value: Map?) = + set(key, value?.let(::InfoPlistMapValue)) - operator fun get(key: InfoPlistKey): String? = values[key] - operator fun set(key: InfoPlistKey, value: String?) { + operator fun set(key: InfoPlistKey, value: InfoPlistValue?) { if (value != null) { values[key] = value } else { @@ -26,13 +70,13 @@ internal class InfoPlistBuilder(private val extraPlistKeysRawXml: String? = null appendLine("") appendLine("") appendLine("") - appendLine(" ") + appendLine("${indentForLevel(1)}") for ((k, v) in values) { - appendLine(" ${k.name}") - appendLine(" $v") + appendLine("${indentForLevel(2)}${k.name}") + appendLine(v.asPlistEntry(2)) } extraPlistKeysRawXml?.let { appendLine(it) } - appendLine(" ") + appendLine("${indentForLevel(1)}") appendLine("") } } @@ -48,6 +92,13 @@ internal object PlistKeys { val LSMinimumSystemVersion by this val CFBundleDevelopmentRegion by this val CFBundleAllowMixedLocalizations by this + val CFBundleDocumentTypes by this + val CFBundleTypeRole by this + val CFBundleTypeExtensions by this + val CFBundleTypeIconFile by this + val CFBundleTypeMIMETypes by this + val CFBundleTypeName by this + val CFBundleTypeOSTypes by this val CFBundleExecutable by this val CFBundleIconFile by this val CFBundleIdentifier by this diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt index d53ad3dd62..83d51089ef 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt @@ -375,6 +375,7 @@ internal fun JvmApplicationContext.configurePlatformSettings( packageTask.linuxRpmLicenseType.set(provider { linux.rpmLicenseType }) packageTask.iconFile.set(linux.iconFile.orElse(defaultResources.get { linuxIcon })) packageTask.installationPath.set(linux.installationPath) + packageTask.fileAssociations.set(provider { linux.fileAssociations }) } } OS.Windows -> { @@ -388,6 +389,7 @@ internal fun JvmApplicationContext.configurePlatformSettings( packageTask.winUpgradeUuid.set(provider { win.upgradeUuid }) packageTask.iconFile.set(win.iconFile.orElse(defaultResources.get { windowsIcon })) packageTask.installationPath.set(win.installationPath) + packageTask.fileAssociations.set(provider { win.fileAssociations }) } } OS.MacOS -> { @@ -414,6 +416,7 @@ internal fun JvmApplicationContext.configurePlatformSettings( packageTask.nonValidatedMacSigningSettings = app.nativeDistributions.macOS.signing packageTask.iconFile.set(mac.iconFile.orElse(defaultResources.get { macIcon })) packageTask.installationPath.set(mac.installationPath) + packageTask.fileAssociations.set(provider { mac.fileAssociations }) } } } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt index ad56727c05..20c3926cd9 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt @@ -9,19 +9,23 @@ import org.gradle.api.file.* import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider +import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.* import org.gradle.api.tasks.Optional import org.gradle.process.ExecResult import org.gradle.work.ChangeType import org.gradle.work.InputChanges +import org.jetbrains.compose.desktop.application.dsl.FileAssociation import org.jetbrains.compose.desktop.application.dsl.MacOSSigningSettings import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.internal.* +import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.* import org.jetbrains.compose.desktop.application.internal.files.* import org.jetbrains.compose.desktop.application.internal.files.MacJarSignFileCopyingProcessor import org.jetbrains.compose.desktop.application.internal.JvmRuntimeProperties import org.jetbrains.compose.desktop.application.internal.validation.validate import org.jetbrains.compose.internal.utils.* +import org.jetbrains.kotlin.gradle.internal.ensureParentDirsCreated import java.io.* import java.nio.file.LinkOption import java.util.* @@ -244,6 +248,39 @@ abstract class AbstractJPackageTask @Inject constructor( @get:Optional val javaRuntimePropertiesFile: RegularFileProperty = objects.fileProperty() + @get:Input + internal val fileAssociations: SetProperty = objects.setProperty(FileAssociation::class.java) + + private val iconMapping by lazy { + val icons = fileAssociations.get().mapNotNull { it.iconFile }.distinct() + if (icons.isEmpty()) return@lazy emptyMap() + val iconTempNames: List = mutableListOf().apply { + val usedNames = mutableSetOf("${packageName.get()}.icns") + for (icon in icons) { + if (!icon.exists()) continue + if (usedNames.add(icon.name)) { + add(icon.name) + continue + } + val nameWithoutExtension = icon.nameWithoutExtension + val extension = icon.extension + for (n in 1UL..ULong.MAX_VALUE) { + val newName = "$nameWithoutExtension ($n).$extension" + if (usedNames.add(newName)) { + add(newName) + break + } + } + } + } + val appDir = destinationDir.ioFile.resolve("${packageName.get()}.app") + val iconsDir = appDir.resolve("Contents").resolve("Resources") + if (iconsDir.exists()) { + iconsDir.deleteRecursively() + } + icons.zip(iconTempNames) { icon, newName -> icon to iconsDir.resolve(newName) }.toMap() + } + private lateinit var jvmRuntimeInfo: JvmRuntimeProperties @get:Optional @@ -273,6 +310,9 @@ abstract class AbstractJPackageTask @Inject constructor( @get:LocalState protected val skikoDir: Provider = project.layout.buildDirectory.dir("compose/tmp/skiko") + @get:LocalState + protected val propertyFilesDir: Provider = project.layout.buildDirectory.dir("compose/tmp/propertyFiles") + @get:Internal private val libsDir: Provider = workingDir.map { it.dir("libs") @@ -368,6 +408,33 @@ abstract class AbstractJPackageTask @Inject constructor( cliArg("--license-file", licenseFile) cliArg("--resource-dir", jpackageResources) + val propertyFilesDirJava = propertyFilesDir.ioFile + fileOperations.clearDirs(propertyFilesDir) + + val fileAssociationFiles = fileAssociations.get() + .groupBy { it.extension } + .mapValues { (extension, associations) -> + associations.mapIndexed { index, association -> + propertyFilesDirJava.resolve("FA${extension}${if (index > 0) index.toString() else ""}.properties") + .apply { + val withoutIcon = """ + mime-type=${association.mimeType} + extension=${association.extension} + description=${association.description} + """.trimIndent() + writeText( + if (association.iconFile == null) withoutIcon + else "${withoutIcon}\nicon=${association.iconFile.normalizedPath()}" + ) + } + } + }.values.flatten() + + for (fileAssociationFile in fileAssociationFiles) { + cliArg("--file-associations", fileAssociationFile) + } + + when (currentOS) { OS.Linux -> { cliArg("--linux-shortcut", linuxShortcut) @@ -569,6 +636,15 @@ abstract class AbstractJPackageTask @Inject constructor( macSigner.sign(runtimeDir, runtimeEntitlementsFile, forceEntitlements = true) macSigner.sign(appDir, appEntitlementsFile, forceEntitlements = true) + + if (iconMapping.isNotEmpty()) { + for ((originalIcon, newIcon) in iconMapping) { + if (originalIcon.exists()) { + newIcon.ensureParentDirsCreated() + originalIcon.copyTo(newIcon) + } + } + } } override fun initState() { @@ -620,6 +696,23 @@ abstract class AbstractJPackageTask @Inject constructor( ?: "Copyright (C) $year" plist[PlistKeys.NSSupportsAutomaticGraphicsSwitching] = "true" plist[PlistKeys.NSHighResolutionCapable] = "true" + val fileAssociationMutableSet = fileAssociations.get() + if (fileAssociationMutableSet.isNotEmpty()) { + plist[PlistKeys.CFBundleDocumentTypes] = fileAssociationMutableSet + .groupBy { it.mimeType to it.description } + .map { (key, extensions) -> + val (mimeType, description) = key + val iconPath = extensions.firstNotNullOfOrNull { it.iconFile }?.let { iconMapping[it]?.name } + InfoPlistMapValue( + PlistKeys.CFBundleTypeRole to InfoPlistStringValue("Editor"), + PlistKeys.CFBundleTypeExtensions to InfoPlistListValue(extensions.map { InfoPlistStringValue(it.extension) }), + PlistKeys.CFBundleTypeIconFile to InfoPlistStringValue(iconPath ?: "$packageName.icns"), + PlistKeys.CFBundleTypeMIMETypes to InfoPlistStringValue(mimeType), + PlistKeys.CFBundleTypeName to InfoPlistStringValue(description), + PlistKeys.CFBundleTypeOSTypes to InfoPlistListValue(InfoPlistStringValue("****")), + ) + } + } } } diff --git a/gradle-plugins/compose/src/test/test-projects/application/macOptions/Expected-Info.plist b/gradle-plugins/compose/src/test/test-projects/application/macOptions/Expected-Info.plist index a54a6e2a56..9f703cf88c 100644 --- a/gradle-plugins/compose/src/test/test-projects/application/macOptions/Expected-Info.plist +++ b/gradle-plugins/compose/src/test/test-projects/application/macOptions/Expected-Info.plist @@ -1,48 +1,123 @@ - - LSMinimumSystemVersion - 12.0 - CFBundleDevelopmentRegion - English - CFBundleAllowMixedLocalizations - true - CFBundleExecutable - TestPackage - CFBundleIconFile - TestPackage.icns - CFBundleIdentifier - MainKt - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - TestPackage - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0.0 - LSApplicationCategoryType - Unknown - CFBundleVersion - 1.0.0 - NSHumanReadableCopyright - Copyright (C) CURRENT_YEAR - NSSupportsAutomaticGraphicsSwitching - true - NSHighResolutionCapable - true + + LSMinimumSystemVersion + 12.0 + CFBundleDevelopmentRegion + English + CFBundleAllowMixedLocalizations + true + CFBundleExecutable + TestPackage + CFBundleIconFile + TestPackage.icns + CFBundleIdentifier + MainKt + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + TestPackage + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + LSApplicationCategoryType + Unknown + CFBundleVersion + 1.0.0 + NSHumanReadableCopyright + Copyright (C) CURRENT_YEAR + NSSupportsAutomaticGraphicsSwitching + true + NSHighResolutionCapable + true + CFBundleDocumentTypes + + + CFBundleTypeRole + Editor + CFBundleTypeExtensions + + kot + + CFBundleTypeIconFile + Kotlin_icon_big.icns + CFBundleTypeMIMETypes + text/kotlin + CFBundleTypeName + Kotlin Source File0 + CFBundleTypeOSTypes + + **** + + + + CFBundleTypeRole + Editor + CFBundleTypeExtensions + + kot1 + + CFBundleTypeIconFile + TestPackage.icns + CFBundleTypeMIMETypes + text/kotlin + CFBundleTypeName + Kotlin Source File1 + CFBundleTypeOSTypes + + **** + + + + CFBundleTypeRole + Editor + CFBundleTypeExtensions + + kott + + CFBundleTypeIconFile + Kotlin_icon_big (1).icns + CFBundleTypeMIMETypes + text/kotlin + CFBundleTypeName + Kotlin Source File2 + CFBundleTypeOSTypes + + **** + + + + CFBundleTypeRole + Editor + CFBundleTypeExtensions + + kott1 + + CFBundleTypeIconFile + TestPackage.icns + CFBundleTypeMIMETypes + text/kotlin + CFBundleTypeName + Kotlin Source File3 + CFBundleTypeOSTypes + + **** + + + - CFBundleURLTypes - - - CFBundleURLName - Exameple URL - CFBundleURLSchemes - - exampleUrl - - - - + CFBundleURLTypes + + + CFBundleURLName + Example URL + CFBundleURLSchemes + + exampleUrl + + + + diff --git a/gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.icns b/gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.icns new file mode 100644 index 0000000000..fedf6a3f2f Binary files /dev/null and b/gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.icns differ diff --git a/gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.ico b/gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.ico new file mode 100644 index 0000000000..3e9c11bb6d Binary files /dev/null and b/gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.ico differ diff --git a/gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.png b/gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.png new file mode 100644 index 0000000000..1f93755e75 Binary files /dev/null and b/gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.png differ diff --git a/gradle-plugins/compose/src/test/test-projects/application/macOptions/build.gradle b/gradle-plugins/compose/src/test/test-projects/application/macOptions/build.gradle index d239392b13..754636aa75 100644 --- a/gradle-plugins/compose/src/test/test-projects/application/macOptions/build.gradle +++ b/gradle-plugins/compose/src/test/test-projects/application/macOptions/build.gradle @@ -10,29 +10,53 @@ dependencies { } def extraInfoPlistKeys = """ - CFBundleURLTypes - - - CFBundleURLName - Exameple URL - CFBundleURLSchemes - - exampleUrl - - - """ + CFBundleURLTypes + + + CFBundleURLName + Example URL + CFBundleURLSchemes + + exampleUrl + + + """ compose.desktop { application { mainClass = "MainKt" nativeDistributions { packageName = "TestPackage" + fileAssociation( + "text/kotlin", + "kot", + "Kotlin Source File0", + project.file("Kotlin_icon_big.png"), + project.file("Kotlin_icon_big.ico"), + project.file("Kotlin_icon_big.icns"), + ) + fileAssociation( + "text/kotlin", + "kot1", + "Kotlin Source File1", + ) macOS { dockName = "CustomDockName" minimumSystemVersion = "12.0" infoPlist { extraKeysRawXml = extraInfoPlistKeys } + fileAssociation( + "text/kotlin", + "kott", + "Kotlin Source File2", + project.file("subdir/Kotlin_icon_big.icns"), + ) + fileAssociation( + "text/kotlin", + "kott1", + "Kotlin Source File3", + ) } } } diff --git a/gradle-plugins/compose/src/test/test-projects/application/macOptions/subdir/Kotlin_icon_big.icns b/gradle-plugins/compose/src/test/test-projects/application/macOptions/subdir/Kotlin_icon_big.icns new file mode 100644 index 0000000000..fedf6a3f2f Binary files /dev/null and b/gradle-plugins/compose/src/test/test-projects/application/macOptions/subdir/Kotlin_icon_big.icns differ