Skip to content

Commit

Permalink
File associations (#4957)
Browse files Browse the repository at this point in the history
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
  ```
  • Loading branch information
zhelenskiy committed Jul 2, 2024
1 parent c7b6403 commit dea37a0
Show file tree
Hide file tree
Showing 12 changed files with 337 additions and 61 deletions.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -32,4 +33,14 @@ abstract class JvmApplicationDistributions : AbstractDistributions() {
fun windows(fn: Action<WindowsPlatformSettings>) {
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -17,6 +18,13 @@ abstract class AbstractPlatformSettings {
val iconFile: RegularFileProperty = objects.fileProperty()
var packageVersion: String? = null
var installationPath: String? = null

internal val fileAssociations: MutableSet<FileAssociation> = mutableSetOf()

@JvmOverloads
fun fileAssociation(mimeType: String, extension: String, description: String, iconFile: File? = null) {
fileAssociations.add(FileAssociation(mimeType, extension, description, iconFile))
}
}

abstract class AbstractMacOSPlatformSettings : AbstractPlatformSettings() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<InfoPlistKey, String>()
internal sealed class InfoPlistValue {
abstract fun asPlistEntry(nestingLevel: Int): String
data class InfoPlistListValue(val elements: List<InfoPlistValue>) : InfoPlistValue() {
override fun asPlistEntry(nestingLevel: Int): String =
if (elements.isEmpty()) "${indentForLevel(nestingLevel)}<array/>"
else elements.joinToString(
separator = "\n",
prefix = "${indentForLevel(nestingLevel)}<array>\n",
postfix = "\n${indentForLevel(nestingLevel)}</array>"
) {
it.asPlistEntry(nestingLevel + 1)
}

constructor(vararg elements: InfoPlistValue) : this(elements.asList())
}

data class InfoPlistMapValue(val elements: Map<InfoPlistKey, InfoPlistValue>) : InfoPlistValue() {
override fun asPlistEntry(nestingLevel: Int): String =
if (elements.isEmpty()) "${indentForLevel(nestingLevel)}<dict/>"
else elements.entries.joinToString(
separator = "\n",
prefix = "${indentForLevel(nestingLevel)}<dict>\n",
postfix = "\n${indentForLevel(nestingLevel)}</dict>",
) { (key, value) ->
"${indentForLevel(nestingLevel + 1)}<key>${key.name}</key>\n${value.asPlistEntry(nestingLevel + 1)}"
}

constructor(vararg elements: Pair<InfoPlistKey, InfoPlistValue>) : this(elements.toMap())
}

data class InfoPlistStringValue(val value: String) : InfoPlistValue() {
override fun asPlistEntry(nestingLevel: Int): String = if (value.isEmpty()) "${indentForLevel(nestingLevel)}<string/>" else "${indentForLevel(nestingLevel)}<string>$value</string>"
}
}

private val values = LinkedHashMap<InfoPlistKey, InfoPlistValue>()

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<InfoPlistValue>?) = set(key, value?.let(::InfoPlistListValue))
operator fun set(key: InfoPlistKey, value: Map<InfoPlistKey, InfoPlistValue>?) =
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 {
Expand All @@ -26,13 +70,13 @@ internal class InfoPlistBuilder(private val extraPlistKeysRawXml: String? = null
appendLine("<?xml version=\"1.0\" ?>")
appendLine("<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"https://www.apple.com/DTDs/PropertyList-1.0.dtd\">")
appendLine("<plist version=\"1.0\">")
appendLine(" <dict>")
appendLine("${indentForLevel(1)}<dict>")
for ((k, v) in values) {
appendLine(" <key>${k.name}</key>")
appendLine(" <string>$v</string>")
appendLine("${indentForLevel(2)}<key>${k.name}</key>")
appendLine(v.asPlistEntry(2))
}
extraPlistKeysRawXml?.let { appendLine(it) }
appendLine(" </dict>")
appendLine("${indentForLevel(1)}</dict>")
appendLine("</plist>")
}
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand All @@ -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 -> {
Expand All @@ -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 })
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -244,6 +248,39 @@ abstract class AbstractJPackageTask @Inject constructor(
@get:Optional
val javaRuntimePropertiesFile: RegularFileProperty = objects.fileProperty()

@get:Input
internal val fileAssociations: SetProperty<FileAssociation> = 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<String> = mutableListOf<String>().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
Expand Down Expand Up @@ -273,6 +310,9 @@ abstract class AbstractJPackageTask @Inject constructor(
@get:LocalState
protected val skikoDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/skiko")

@get:LocalState
protected val propertyFilesDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/propertyFiles")

@get:Internal
private val libsDir: Provider<Directory> = workingDir.map {
it.dir("libs")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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("****")),
)
}
}
}
}

Expand Down
Loading

0 comments on commit dea37a0

Please sign in to comment.