Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

File associations #4957

Merged
merged 10 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
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)
zhelenskiy marked this conversation as resolved.
Show resolved Hide resolved

private val iconMapping by lazy {
val icons = fileAssociations.orNull.orEmpty().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.orNull.orEmpty()
.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.orNull
if (!fileAssociationMutableSet.isNullOrEmpty()) {
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
Loading