Skip to content

Commit dea37a0

Browse files
authored
File associations (#4957)
Add file associations support to Compose Desktop <!-- Optional --> Fixes #773 ## Testing Tested on the [sample project](https://github.com/zhelenskiy/file-associations-demo). Behaviours per OSs: - MacOS Sonoma: associations work for distributables. - Windows 11: associations work after the installation of the MSI. - Kubuntu: associations do not work, but everything else works fine. However, IDEA also does not have associations there, so I assume this is fine. I didn't write any unit tests because I don’t know which of them you are expecting me to write. So, I'm looking forward to your feedback and suggestions. <!-- Optional --> This should be tested by QA ## Release Notes <!-- Optional, if omitted - won't be included in the changelog Sections: - Highlights - Known issues - Breaking changes - Features - Fixes Subsections: - Multiple Platforms - iOS - Desktop - Web - Resources - Gradle Plugin --> ### Highlight - Desktop - Introduction of the new DSL function in `nativeDistributions` block: ```kotlin fun fileAssociation(mimeType: String, extension: String, description: String): Unit ```
1 parent c7b6403 commit dea37a0

File tree

12 files changed

+337
-61
lines changed

12 files changed

+337
-61
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package org.jetbrains.compose.desktop.application.dsl
2+
3+
import java.io.File
4+
import java.io.Serializable
5+
6+
internal data class FileAssociation(
7+
val mimeType: String,
8+
val extension: String,
9+
val description: String,
10+
val iconFile: File?,
11+
) : Serializable

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

Lines changed: 11 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 java.io.File
910

1011
internal val DEFAULT_RUNTIME_MODULES = arrayOf(
1112
"java.base", "java.desktop", "java.logging", "jdk.crypto.ec"
@@ -32,4 +33,14 @@ abstract class JvmApplicationDistributions : AbstractDistributions() {
3233
fun windows(fn: Action<WindowsPlatformSettings>) {
3334
fn.execute(windows)
3435
}
36+
37+
@JvmOverloads
38+
fun fileAssociation(
39+
mimeType: String, extension: String, description: String,
40+
linuxIconFile: File? = null, windowsIconFile: File? = null, macOSIconFile: File? = null,
41+
) {
42+
linux.fileAssociation(mimeType, extension, description, linuxIconFile)
43+
windows.fileAssociation(mimeType, extension, description, windowsIconFile)
44+
macOS.fileAssociation(mimeType, extension, description, macOSIconFile)
45+
}
3546
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package org.jetbrains.compose.desktop.application.dsl
88
import org.gradle.api.Action
99
import org.gradle.api.file.RegularFileProperty
1010
import org.gradle.api.model.ObjectFactory
11+
import java.io.File
1112
import javax.inject.Inject
1213

1314
abstract class AbstractPlatformSettings {
@@ -17,6 +18,13 @@ abstract class AbstractPlatformSettings {
1718
val iconFile: RegularFileProperty = objects.fileProperty()
1819
var packageVersion: String? = null
1920
var installationPath: String? = null
21+
22+
internal val fileAssociations: MutableSet<FileAssociation> = mutableSetOf()
23+
24+
@JvmOverloads
25+
fun fileAssociation(mimeType: String, extension: String, description: String, iconFile: File? = null) {
26+
fileAssociations.add(FileAssociation(mimeType, extension, description, iconFile))
27+
}
2028
}
2129

2230
abstract class AbstractMacOSPlatformSettings : AbstractPlatformSettings() {

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

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,58 @@
55

66
package org.jetbrains.compose.desktop.application.internal
77

8+
import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.*
89
import java.io.File
910
import kotlin.reflect.KProperty
1011

12+
private const val indent = " "
13+
private fun indentForLevel(level: Int) = indent.repeat(level)
14+
1115
internal class InfoPlistBuilder(private val extraPlistKeysRawXml: String? = null) {
12-
private val values = LinkedHashMap<InfoPlistKey, String>()
16+
internal sealed class InfoPlistValue {
17+
abstract fun asPlistEntry(nestingLevel: Int): String
18+
data class InfoPlistListValue(val elements: List<InfoPlistValue>) : InfoPlistValue() {
19+
override fun asPlistEntry(nestingLevel: Int): String =
20+
if (elements.isEmpty()) "${indentForLevel(nestingLevel)}<array/>"
21+
else elements.joinToString(
22+
separator = "\n",
23+
prefix = "${indentForLevel(nestingLevel)}<array>\n",
24+
postfix = "\n${indentForLevel(nestingLevel)}</array>"
25+
) {
26+
it.asPlistEntry(nestingLevel + 1)
27+
}
28+
29+
constructor(vararg elements: InfoPlistValue) : this(elements.asList())
30+
}
31+
32+
data class InfoPlistMapValue(val elements: Map<InfoPlistKey, InfoPlistValue>) : InfoPlistValue() {
33+
override fun asPlistEntry(nestingLevel: Int): String =
34+
if (elements.isEmpty()) "${indentForLevel(nestingLevel)}<dict/>"
35+
else elements.entries.joinToString(
36+
separator = "\n",
37+
prefix = "${indentForLevel(nestingLevel)}<dict>\n",
38+
postfix = "\n${indentForLevel(nestingLevel)}</dict>",
39+
) { (key, value) ->
40+
"${indentForLevel(nestingLevel + 1)}<key>${key.name}</key>\n${value.asPlistEntry(nestingLevel + 1)}"
41+
}
42+
43+
constructor(vararg elements: Pair<InfoPlistKey, InfoPlistValue>) : this(elements.toMap())
44+
}
45+
46+
data class InfoPlistStringValue(val value: String) : InfoPlistValue() {
47+
override fun asPlistEntry(nestingLevel: Int): String = if (value.isEmpty()) "${indentForLevel(nestingLevel)}<string/>" else "${indentForLevel(nestingLevel)}<string>$value</string>"
48+
}
49+
}
50+
51+
private val values = LinkedHashMap<InfoPlistKey, InfoPlistValue>()
52+
53+
operator fun get(key: InfoPlistKey): InfoPlistValue? = values[key]
54+
operator fun set(key: InfoPlistKey, value: String?) = set(key, value?.let(::InfoPlistStringValue))
55+
operator fun set(key: InfoPlistKey, value: List<InfoPlistValue>?) = set(key, value?.let(::InfoPlistListValue))
56+
operator fun set(key: InfoPlistKey, value: Map<InfoPlistKey, InfoPlistValue>?) =
57+
set(key, value?.let(::InfoPlistMapValue))
1358

14-
operator fun get(key: InfoPlistKey): String? = values[key]
15-
operator fun set(key: InfoPlistKey, value: String?) {
59+
operator fun set(key: InfoPlistKey, value: InfoPlistValue?) {
1660
if (value != null) {
1761
values[key] = value
1862
} else {
@@ -26,13 +70,13 @@ internal class InfoPlistBuilder(private val extraPlistKeysRawXml: String? = null
2670
appendLine("<?xml version=\"1.0\" ?>")
2771
appendLine("<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"https://www.apple.com/DTDs/PropertyList-1.0.dtd\">")
2872
appendLine("<plist version=\"1.0\">")
29-
appendLine(" <dict>")
73+
appendLine("${indentForLevel(1)}<dict>")
3074
for ((k, v) in values) {
31-
appendLine(" <key>${k.name}</key>")
32-
appendLine(" <string>$v</string>")
75+
appendLine("${indentForLevel(2)}<key>${k.name}</key>")
76+
appendLine(v.asPlistEntry(2))
3377
}
3478
extraPlistKeysRawXml?.let { appendLine(it) }
35-
appendLine(" </dict>")
79+
appendLine("${indentForLevel(1)}</dict>")
3680
appendLine("</plist>")
3781
}
3882
}
@@ -48,6 +92,13 @@ internal object PlistKeys {
4892
val LSMinimumSystemVersion by this
4993
val CFBundleDevelopmentRegion by this
5094
val CFBundleAllowMixedLocalizations by this
95+
val CFBundleDocumentTypes by this
96+
val CFBundleTypeRole by this
97+
val CFBundleTypeExtensions by this
98+
val CFBundleTypeIconFile by this
99+
val CFBundleTypeMIMETypes by this
100+
val CFBundleTypeName by this
101+
val CFBundleTypeOSTypes by this
51102
val CFBundleExecutable by this
52103
val CFBundleIconFile by this
53104
val CFBundleIdentifier by this

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ internal fun JvmApplicationContext.configurePlatformSettings(
375375
packageTask.linuxRpmLicenseType.set(provider { linux.rpmLicenseType })
376376
packageTask.iconFile.set(linux.iconFile.orElse(defaultResources.get { linuxIcon }))
377377
packageTask.installationPath.set(linux.installationPath)
378+
packageTask.fileAssociations.set(provider { linux.fileAssociations })
378379
}
379380
}
380381
OS.Windows -> {
@@ -388,6 +389,7 @@ internal fun JvmApplicationContext.configurePlatformSettings(
388389
packageTask.winUpgradeUuid.set(provider { win.upgradeUuid })
389390
packageTask.iconFile.set(win.iconFile.orElse(defaultResources.get { windowsIcon }))
390391
packageTask.installationPath.set(win.installationPath)
392+
packageTask.fileAssociations.set(provider { win.fileAssociations })
391393
}
392394
}
393395
OS.MacOS -> {
@@ -414,6 +416,7 @@ internal fun JvmApplicationContext.configurePlatformSettings(
414416
packageTask.nonValidatedMacSigningSettings = app.nativeDistributions.macOS.signing
415417
packageTask.iconFile.set(mac.iconFile.orElse(defaultResources.get { macIcon }))
416418
packageTask.installationPath.set(mac.installationPath)
419+
packageTask.fileAssociations.set(provider { mac.fileAssociations })
417420
}
418421
}
419422
}

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,23 @@ import org.gradle.api.file.*
99
import org.gradle.api.provider.ListProperty
1010
import org.gradle.api.provider.Property
1111
import org.gradle.api.provider.Provider
12+
import org.gradle.api.provider.SetProperty
1213
import org.gradle.api.tasks.*
1314
import org.gradle.api.tasks.Optional
1415
import org.gradle.process.ExecResult
1516
import org.gradle.work.ChangeType
1617
import org.gradle.work.InputChanges
18+
import org.jetbrains.compose.desktop.application.dsl.FileAssociation
1719
import org.jetbrains.compose.desktop.application.dsl.MacOSSigningSettings
1820
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
1921
import org.jetbrains.compose.desktop.application.internal.*
22+
import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.*
2023
import org.jetbrains.compose.desktop.application.internal.files.*
2124
import org.jetbrains.compose.desktop.application.internal.files.MacJarSignFileCopyingProcessor
2225
import org.jetbrains.compose.desktop.application.internal.JvmRuntimeProperties
2326
import org.jetbrains.compose.desktop.application.internal.validation.validate
2427
import org.jetbrains.compose.internal.utils.*
28+
import org.jetbrains.kotlin.gradle.internal.ensureParentDirsCreated
2529
import java.io.*
2630
import java.nio.file.LinkOption
2731
import java.util.*
@@ -244,6 +248,39 @@ abstract class AbstractJPackageTask @Inject constructor(
244248
@get:Optional
245249
val javaRuntimePropertiesFile: RegularFileProperty = objects.fileProperty()
246250

251+
@get:Input
252+
internal val fileAssociations: SetProperty<FileAssociation> = objects.setProperty(FileAssociation::class.java)
253+
254+
private val iconMapping by lazy {
255+
val icons = fileAssociations.get().mapNotNull { it.iconFile }.distinct()
256+
if (icons.isEmpty()) return@lazy emptyMap()
257+
val iconTempNames: List<String> = mutableListOf<String>().apply {
258+
val usedNames = mutableSetOf("${packageName.get()}.icns")
259+
for (icon in icons) {
260+
if (!icon.exists()) continue
261+
if (usedNames.add(icon.name)) {
262+
add(icon.name)
263+
continue
264+
}
265+
val nameWithoutExtension = icon.nameWithoutExtension
266+
val extension = icon.extension
267+
for (n in 1UL..ULong.MAX_VALUE) {
268+
val newName = "$nameWithoutExtension ($n).$extension"
269+
if (usedNames.add(newName)) {
270+
add(newName)
271+
break
272+
}
273+
}
274+
}
275+
}
276+
val appDir = destinationDir.ioFile.resolve("${packageName.get()}.app")
277+
val iconsDir = appDir.resolve("Contents").resolve("Resources")
278+
if (iconsDir.exists()) {
279+
iconsDir.deleteRecursively()
280+
}
281+
icons.zip(iconTempNames) { icon, newName -> icon to iconsDir.resolve(newName) }.toMap()
282+
}
283+
247284
private lateinit var jvmRuntimeInfo: JvmRuntimeProperties
248285

249286
@get:Optional
@@ -273,6 +310,9 @@ abstract class AbstractJPackageTask @Inject constructor(
273310
@get:LocalState
274311
protected val skikoDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/skiko")
275312

313+
@get:LocalState
314+
protected val propertyFilesDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/propertyFiles")
315+
276316
@get:Internal
277317
private val libsDir: Provider<Directory> = workingDir.map {
278318
it.dir("libs")
@@ -368,6 +408,33 @@ abstract class AbstractJPackageTask @Inject constructor(
368408
cliArg("--license-file", licenseFile)
369409
cliArg("--resource-dir", jpackageResources)
370410

411+
val propertyFilesDirJava = propertyFilesDir.ioFile
412+
fileOperations.clearDirs(propertyFilesDir)
413+
414+
val fileAssociationFiles = fileAssociations.get()
415+
.groupBy { it.extension }
416+
.mapValues { (extension, associations) ->
417+
associations.mapIndexed { index, association ->
418+
propertyFilesDirJava.resolve("FA${extension}${if (index > 0) index.toString() else ""}.properties")
419+
.apply {
420+
val withoutIcon = """
421+
mime-type=${association.mimeType}
422+
extension=${association.extension}
423+
description=${association.description}
424+
""".trimIndent()
425+
writeText(
426+
if (association.iconFile == null) withoutIcon
427+
else "${withoutIcon}\nicon=${association.iconFile.normalizedPath()}"
428+
)
429+
}
430+
}
431+
}.values.flatten()
432+
433+
for (fileAssociationFile in fileAssociationFiles) {
434+
cliArg("--file-associations", fileAssociationFile)
435+
}
436+
437+
371438
when (currentOS) {
372439
OS.Linux -> {
373440
cliArg("--linux-shortcut", linuxShortcut)
@@ -569,6 +636,15 @@ abstract class AbstractJPackageTask @Inject constructor(
569636

570637
macSigner.sign(runtimeDir, runtimeEntitlementsFile, forceEntitlements = true)
571638
macSigner.sign(appDir, appEntitlementsFile, forceEntitlements = true)
639+
640+
if (iconMapping.isNotEmpty()) {
641+
for ((originalIcon, newIcon) in iconMapping) {
642+
if (originalIcon.exists()) {
643+
newIcon.ensureParentDirsCreated()
644+
originalIcon.copyTo(newIcon)
645+
}
646+
}
647+
}
572648
}
573649

574650
override fun initState() {
@@ -620,6 +696,23 @@ abstract class AbstractJPackageTask @Inject constructor(
620696
?: "Copyright (C) $year"
621697
plist[PlistKeys.NSSupportsAutomaticGraphicsSwitching] = "true"
622698
plist[PlistKeys.NSHighResolutionCapable] = "true"
699+
val fileAssociationMutableSet = fileAssociations.get()
700+
if (fileAssociationMutableSet.isNotEmpty()) {
701+
plist[PlistKeys.CFBundleDocumentTypes] = fileAssociationMutableSet
702+
.groupBy { it.mimeType to it.description }
703+
.map { (key, extensions) ->
704+
val (mimeType, description) = key
705+
val iconPath = extensions.firstNotNullOfOrNull { it.iconFile }?.let { iconMapping[it]?.name }
706+
InfoPlistMapValue(
707+
PlistKeys.CFBundleTypeRole to InfoPlistStringValue("Editor"),
708+
PlistKeys.CFBundleTypeExtensions to InfoPlistListValue(extensions.map { InfoPlistStringValue(it.extension) }),
709+
PlistKeys.CFBundleTypeIconFile to InfoPlistStringValue(iconPath ?: "$packageName.icns"),
710+
PlistKeys.CFBundleTypeMIMETypes to InfoPlistStringValue(mimeType),
711+
PlistKeys.CFBundleTypeName to InfoPlistStringValue(description),
712+
PlistKeys.CFBundleTypeOSTypes to InfoPlistListValue(InfoPlistStringValue("****")),
713+
)
714+
}
715+
}
623716
}
624717
}
625718

0 commit comments

Comments
 (0)