Browse Source

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
  ```
pull/5076/head v1.7.0-alpha01
Evgeniy Zhelenskiy 5 months ago committed by GitHub
parent
commit
dea37a012d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/FileAssociation.kt
  2. 11
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/JvmApplicationDistributions.kt
  3. 8
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt
  4. 65
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/InfoPlistBuilder.kt
  5. 3
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt
  6. 93
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt
  7. 161
      gradle-plugins/compose/src/test/test-projects/application/macOptions/Expected-Info.plist
  8. BIN
      gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.icns
  9. BIN
      gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.ico
  10. BIN
      gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.png
  11. 46
      gradle-plugins/compose/src/test/test-projects/application/macOptions/build.gradle
  12. BIN
      gradle-plugins/compose/src/test/test-projects/application/macOptions/subdir/Kotlin_icon_big.icns

11
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

11
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<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)
}
}

8
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<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() {

65
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<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 {
@ -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>")
}
}
@ -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

3
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 })
}
}
}

93
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<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
@ -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")
@ -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("****")),
)
}
}
}
}

161
gradle-plugins/compose/src/test/test-projects/application/macOptions/Expected-Info.plist

@ -1,48 +1,123 @@
<?xml version="1.0" ?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleAllowMixedLocalizations</key>
<string>true</string>
<key>CFBundleExecutable</key>
<string>TestPackage</string>
<key>CFBundleIconFile</key>
<string>TestPackage.icns</string>
<key>CFBundleIdentifier</key>
<string>MainKt</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>TestPackage</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>LSApplicationCategoryType</key>
<string>Unknown</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (C) CURRENT_YEAR</string>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<string>true</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<dict>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleAllowMixedLocalizations</key>
<string>true</string>
<key>CFBundleExecutable</key>
<string>TestPackage</string>
<key>CFBundleIconFile</key>
<string>TestPackage.icns</string>
<key>CFBundleIdentifier</key>
<string>MainKt</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>TestPackage</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>LSApplicationCategoryType</key>
<string>Unknown</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (C) CURRENT_YEAR</string>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<string>true</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>kot</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>Kotlin_icon_big.icns</string>
<key>CFBundleTypeMIMETypes</key>
<string>text/kotlin</string>
<key>CFBundleTypeName</key>
<string>Kotlin Source File0</string>
<key>CFBundleTypeOSTypes</key>
<array>
<string>****</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>kot1</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>TestPackage.icns</string>
<key>CFBundleTypeMIMETypes</key>
<string>text/kotlin</string>
<key>CFBundleTypeName</key>
<string>Kotlin Source File1</string>
<key>CFBundleTypeOSTypes</key>
<array>
<string>****</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>kott</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>Kotlin_icon_big (1).icns</string>
<key>CFBundleTypeMIMETypes</key>
<string>text/kotlin</string>
<key>CFBundleTypeName</key>
<string>Kotlin Source File2</string>
<key>CFBundleTypeOSTypes</key>
<array>
<string>****</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>kott1</string>
</array>
<key>CFBundleTypeIconFile</key>
<string>TestPackage.icns</string>
<key>CFBundleTypeMIMETypes</key>
<string>text/kotlin</string>
<key>CFBundleTypeName</key>
<string>Kotlin Source File3</string>
<key>CFBundleTypeOSTypes</key>
<array>
<string>****</string>
</array>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Exameple URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>exampleUrl</string>
</array>
</dict>
</array>
</dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Example URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>exampleUrl</string>
</array>
</dict>
</array>
</dict>
</plist>

BIN
gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.icns

Binary file not shown.

BIN
gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
gradle-plugins/compose/src/test/test-projects/application/macOptions/Kotlin_icon_big.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

46
gradle-plugins/compose/src/test/test-projects/application/macOptions/build.gradle

@ -10,29 +10,53 @@ dependencies {
}
def extraInfoPlistKeys = """
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Exameple URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>exampleUrl</string>
</array>
</dict>
</array>"""
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Example URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>exampleUrl</string>
</array>
</dict>
</array>"""
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",
)
}
}
}

BIN
gradle-plugins/compose/src/test/test-projects/application/macOptions/subdir/Kotlin_icon_big.icns

Binary file not shown.
Loading…
Cancel
Save