You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

670 lines
25 KiB

/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.application.tasks
import org.gradle.api.file.*
import org.gradle.api.internal.file.FileOperations
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
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.MacOSSigningSettings
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.internal.*
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 java.io.*
import java.util.*
import javax.inject.Inject
import kotlin.collections.HashMap
import kotlin.collections.HashSet
import kotlin.collections.ArrayList
abstract class AbstractJPackageTask @Inject constructor(
@get:Input
val targetFormat: TargetFormat,
) : AbstractJvmToolOperationTask("jpackage") {
@get:InputFiles
val files: ConfigurableFileCollection = objects.fileCollection()
/**
* A hack to avoid conflicts between jar files in a flat dir.
* We receive input jar files as a list (FileCollection) of files.
* At that point we don't have access to jar files' coordinates.
*
* Some files can have the same simple names.
* For example, a project containing two modules:
* 1. :data:utils
* 2. :ui:utils
* produces:
* 1. <PROJECT>/data/utils/build/../utils.jar
* 2. <PROJECT>/ui/utils/build/../utils.jar
*
* jpackage expects all files to be in one input directory (not sure),
* so the natural workaround to avoid overwrites/conflicts is to add a content hash
* to a file name. A better solution would be to preserve coordinates or relative paths,
* but it is not straightforward at this point.
*
* The flag is needed for two things:
* 1. Give users the ability to turn off the mangling, if they need to preserve names;
* 2. Proguard transformation already flattens jar files & mangles names, so we don't
* need to mangle twice.
*/
@get:Input
val mangleJarFilesNames: Property<Boolean> = objects.notNullProperty(true)
@get:InputDirectory
@get:Optional
/** @see internal/wixToolset.kt */
val wixToolsetDir: DirectoryProperty = objects.directoryProperty()
@get:Input
@get:Optional
val installationPath: Property<String?> = objects.nullableProperty()
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.ABSOLUTE)
val licenseFile: RegularFileProperty = objects.fileProperty()
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.ABSOLUTE)
val iconFile: RegularFileProperty = objects.fileProperty()
@get:Input
val launcherMainClass: Property<String> = objects.notNullProperty()
@get:InputFile
@get:PathSensitive(PathSensitivity.ABSOLUTE)
val launcherMainJar: RegularFileProperty = objects.fileProperty()
@get:Input
@get:Optional
val launcherArgs: ListProperty<String> = objects.listProperty(String::class.java)
@get:Input
@get:Optional
val launcherJvmArgs: ListProperty<String> = objects.listProperty(String::class.java)
@get:Input
val packageName: Property<String> = objects.notNullProperty()
@get:Input
@get:Optional
val packageDescription: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val packageCopyright: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val packageVendor: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val packageVersion: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val linuxShortcut: Property<Boolean?> = objects.nullableProperty()
@get:Input
@get:Optional
val linuxPackageName: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val linuxAppRelease: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val linuxAppCategory: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val linuxDebMaintainer: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val linuxMenuGroup: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val linuxRpmLicenseType: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val macPackageName: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val macDockName: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val macAppStore: Property<Boolean?> = objects.nullableProperty()
@get:Input
@get:Optional
val macAppCategory: Property<String?> = objects.nullableProperty()
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.ABSOLUTE)
val macEntitlementsFile: RegularFileProperty = objects.fileProperty()
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.ABSOLUTE)
val macRuntimeEntitlementsFile: RegularFileProperty = objects.fileProperty()
@get:Input
@get:Optional
val packageBuildVersion: Property<String?> = objects.nullableProperty()
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.ABSOLUTE)
val macProvisioningProfile: RegularFileProperty = objects.fileProperty()
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.ABSOLUTE)
val macRuntimeProvisioningProfile: RegularFileProperty = objects.fileProperty()
@get:Input
@get:Optional
val winConsole: Property<Boolean?> = objects.nullableProperty()
@get:Input
@get:Optional
val winDirChooser: Property<Boolean?> = objects.nullableProperty()
@get:Input
@get:Optional
val winPerUserInstall: Property<Boolean?> = objects.nullableProperty()
@get:Input
@get:Optional
val winShortcut: Property<Boolean?> = objects.nullableProperty()
@get:Input
@get:Optional
val winMenu: Property<Boolean?> = objects.nullableProperty()
@get:Input
@get:Optional
val winMenuGroup: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val winUpgradeUuid: Property<String?> = objects.nullableProperty()
@get:InputDirectory
@get:Optional
val runtimeImage: DirectoryProperty = objects.directoryProperty()
@get:InputDirectory
@get:Optional
val appImage: DirectoryProperty = objects.directoryProperty()
@get:Input
@get:Optional
internal val nonValidatedMacBundleID: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
internal val macExtraPlistKeysRawXml: Property<String?> = objects.nullableProperty()
@get:InputFile
@get:Optional
val javaRuntimePropertiesFile: RegularFileProperty = objects.fileProperty()
private lateinit var jvmRuntimeInfo: JvmRuntimeProperties
@get:Optional
@get:Nested
internal var nonValidatedMacSigningSettings: MacOSSigningSettings? = null
private val macSigner: MacSigner? by lazy {
val nonValidatedSettings = nonValidatedMacSigningSettings
if (currentOS == OS.MacOS && nonValidatedSettings?.sign?.get() == true) {
val validatedSettings =
nonValidatedSettings.validate(nonValidatedMacBundleID, project, macAppStore)
MacSigner(validatedSettings, runExternalTool)
} else null
}
@get:LocalState
protected val signDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/sign")
@get:LocalState
protected val jpackageResources: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/resources")
@get:LocalState
protected val skikoDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/skiko")
@get:Internal
private val libsDir: Provider<Directory> = workingDir.map {
it.dir("libs")
}
@get:Internal
private val packagedResourcesDir: Provider<Directory> = libsDir.map {
it.dir("resources")
}
@get:Internal
val appResourcesDir: DirectoryProperty = objects.directoryProperty()
/**
* Gradle runtime verification fails,
* if InputDirectory is not null, but a directory does not exist.
* The directory might not exist, because prepareAppResources task
* does not create output directory if there are no resources.
*
* To work around this, appResourcesDir is used as a real property,
* but it is annotated as @Internal, so it ignored during inputs checking.
* This property is used only for inputs checking.
* It returns appResourcesDir value if the underlying directory exists.
*/
@Suppress("unused")
@get:InputDirectory
@get:Optional
internal val appResourcesDirInputDirHackForVerification: Provider<Directory>
get() = appResourcesDir.map { it.takeIf { it.asFile.exists() } }
@get:Internal
private val libsMappingFile: Provider<RegularFile> = workingDir.map {
it.file("libs-mapping.txt")
}
@get:Internal
private val libsMapping = FilesMapping()
override fun makeArgs(tmpDir: File): MutableList<String> = super.makeArgs(tmpDir).apply {
fun appDir(vararg pathParts: String): String {
/** For windows we need to pass '\\' to jpackage file, each '\' need to be escaped.
Otherwise '$APPDIR\resources' is passed to jpackage,
and '\r' is treated as a special character at run time.
*/
val separator = if (currentTarget.os == OS.Windows) "\\\\" else "/"
return listOf("${'$'}APPDIR", *pathParts).joinToString(separator) { it }
}
if (targetFormat == TargetFormat.AppImage || appImage.orNull == null) {
// Args, that can only be used, when creating an app image or an installer w/o --app-image parameter
cliArg("--input", libsDir)
cliArg("--runtime-image", runtimeImage)
cliArg("--resource-dir", jpackageResources)
javaOption("-D$APP_RESOURCES_DIR=${appDir(packagedResourcesDir.ioFile.name)}")
val mappedJar = libsMapping[launcherMainJar.ioFile]?.singleOrNull()
?: error("Main jar was not processed correctly: ${launcherMainJar.ioFile}")
cliArg("--main-jar", mappedJar)
cliArg("--main-class", launcherMainClass)
if (currentOS == OS.Windows) {
cliArg("--win-console", winConsole)
}
cliArg("--icon", iconFile)
launcherArgs.orNull?.forEach {
cliArg("--arguments", "'$it'")
}
launcherJvmArgs.orNull?.forEach {
javaOption(it)
}
javaOption("-D$SKIKO_LIBRARY_PATH=${appDir()}")
if (currentOS == OS.MacOS) {
macDockName.orNull?.let { dockName ->
javaOption("-Xdock:name=$dockName")
}
macProvisioningProfile.orNull?.let { provisioningProfile ->
cliArg("--app-content", provisioningProfile)
}
}
}
if (targetFormat != TargetFormat.AppImage) {
// Args, that can only be used, when creating an installer
if (currentOS == OS.MacOS && jvmRuntimeInfo.majorVersion >= 18) {
// This is needed to prevent a directory does not exist error.
cliArg("--app-image", appImage.dir("${packageName.get()}.app"))
} else {
cliArg("--app-image", appImage)
}
cliArg("--install-dir", installationPath)
cliArg("--license-file", licenseFile)
cliArg("--resource-dir", jpackageResources)
when (currentOS) {
OS.Linux -> {
cliArg("--linux-shortcut", linuxShortcut)
cliArg("--linux-package-name", linuxPackageName)
cliArg("--linux-app-release", linuxAppRelease)
cliArg("--linux-app-category", linuxAppCategory)
cliArg("--linux-deb-maintainer", linuxDebMaintainer)
cliArg("--linux-menu-group", linuxMenuGroup)
cliArg("--linux-rpm-license-type", linuxRpmLicenseType)
}
OS.Windows -> {
cliArg("--win-dir-chooser", winDirChooser)
cliArg("--win-per-user-install", winPerUserInstall)
cliArg("--win-shortcut", winShortcut)
cliArg("--win-menu", winMenu)
cliArg("--win-menu-group", winMenuGroup)
cliArg("--win-upgrade-uuid", winUpgradeUuid)
}
OS.MacOS -> {}
}
}
cliArg("--type", targetFormat.id)
cliArg("--dest", destinationDir)
cliArg("--verbose", verbose)
cliArg("--name", packageName)
cliArg("--description", packageDescription)
cliArg("--copyright", packageCopyright)
cliArg("--app-version", packageVersion)
cliArg("--vendor", packageVendor)
if (currentOS == OS.MacOS) {
cliArg("--mac-package-name", macPackageName)
cliArg("--mac-package-identifier", nonValidatedMacBundleID)
cliArg("--mac-app-store", macAppStore)
cliArg("--mac-app-category", macAppCategory)
cliArg("--mac-entitlements", macEntitlementsFile)
macSigner?.let { signer ->
cliArg("--mac-sign", true)
cliArg("--mac-signing-key-user-name", signer.settings.identity)
cliArg("--mac-signing-keychain", signer.settings.keychain)
cliArg("--mac-package-signing-prefix", signer.settings.prefix)
}
}
}
private fun invalidateMappedLibs(
inputChanges: InputChanges
): Set<File> {
val outdatedLibs = HashSet<File>()
val libsDirFile = libsDir.ioFile
fun invalidateAllLibs() {
outdatedLibs.addAll(files.files)
logger.debug("Clearing all files in working dir: $libsDirFile")
fileOperations.delete(libsDirFile)
libsDirFile.mkdirs()
}
if (inputChanges.isIncremental) {
val allChanges = inputChanges.getFileChanges(files).asSequence()
try {
for (change in allChanges) {
libsMapping.remove(change.file)?.let { files ->
files.forEach { fileOperations.delete(it) }
}
if (change.changeType != ChangeType.REMOVED) {
outdatedLibs.add(change.file)
}
}
} catch (e: Exception) {
logger.debug("Could remove outdated libs incrementally: ${e.stacktraceToString()}")
invalidateAllLibs()
}
} else {
invalidateAllLibs()
}
return outdatedLibs
}
override fun prepareWorkingDir(inputChanges: InputChanges) {
val libsDir = libsDir.ioFile
val fileProcessor =
macSigner?.let { signer ->
val tmpDirForSign = signDir.ioFile
fileOperations.delete(tmpDirForSign)
tmpDirForSign.mkdirs()
MacJarSignFileCopyingProcessor(
signer,
tmpDirForSign,
jvmRuntimeVersion = jvmRuntimeInfo.majorVersion
)
} ?: SimpleFileCopyingProcessor
val mangleJarFilesNames = mangleJarFilesNames.get()
fun copyFileToLibsDir(sourceFile: File): File {
val targetName =
if (mangleJarFilesNames && sourceFile.isJarFile) sourceFile.mangledName()
else sourceFile.name
val targetFile = libsDir.resolve(targetName)
fileProcessor.copy(sourceFile, targetFile)
return targetFile
}
val outdatedLibs = invalidateMappedLibs(inputChanges)
for (sourceFile in outdatedLibs) {
assert(sourceFile.exists()) { "Lib file does not exist: $sourceFile" }
libsMapping[sourceFile] = if (isSkikoForCurrentOS(sourceFile)) {
val unpackedFiles = unpackSkikoForCurrentOS(sourceFile, skikoDir.ioFile, fileOperations)
unpackedFiles.map { copyFileToLibsDir(it) }
} else {
listOf(copyFileToLibsDir(sourceFile))
}
}
// todo: incremental copy
cleanDirs(packagedResourcesDir)
val destResourcesDir = packagedResourcesDir.ioFile
val appResourcesDir = appResourcesDir.ioFileOrNull
if (appResourcesDir != null) {
for (file in appResourcesDir.walk()) {
val relPath = file.relativeTo(appResourcesDir).path
val destFile = destResourcesDir.resolve(relPath)
if (file.isDirectory) {
fileOperations.mkdir(destFile)
} else {
file.copyTo(destFile)
}
}
}
cleanDirs(jpackageResources)
if (currentOS == OS.MacOS) {
InfoPlistBuilder(macExtraPlistKeysRawXml.orNull)
.also { setInfoPlistValues(it) }
.writeToFile(jpackageResources.ioFile.resolve("Info.plist"))
if (macAppStore.orNull == true) {
val productDefPlistXml = """
<key>os</key>
<array>
<string>10.13</string>
</array>
""".trimIndent()
InfoPlistBuilder(productDefPlistXml)
.writeToFile(jpackageResources.ioFile.resolve("product-def.plist"))
}
}
}
override fun jvmToolEnvironment(): MutableMap<String, String> =
super.jvmToolEnvironment().apply {
if (currentOS == OS.Windows) {
val wixDir = wixToolsetDir.ioFile
val wixPath = wixDir.absolutePath
val path = System.getenv("PATH") ?: ""
put("PATH", "$wixPath;$path")
}
}
override fun checkResult(result: ExecResult) {
super.checkResult(result)
modifyRuntimeOnMacOsIfNeeded()
val outputFile = findOutputFileOrDir(destinationDir.ioFile, targetFormat)
logger.lifecycle("The distribution is written to ${outputFile.canonicalPath}")
}
private fun modifyRuntimeOnMacOsIfNeeded() {
if (currentOS != OS.MacOS || targetFormat != TargetFormat.AppImage) return
macSigner?.let { macSigner ->
val macSigningHelper = MacSigningHelper(
macSigner = macSigner,
runtimeProvisioningProfile = macRuntimeProvisioningProfile.ioFileOrNull,
entitlementsFile = macEntitlementsFile.ioFileOrNull,
runtimeEntitlementsFile = macRuntimeEntitlementsFile.ioFileOrNull,
destinationDir = destinationDir.ioFile,
packageName = packageName.get()
)
macSigningHelper.modifyRuntimeIfNeeded()
}
}
override fun initState() {
jvmRuntimeInfo = JvmRuntimeProperties.readFromFile(javaRuntimePropertiesFile.ioFile)
val mappingFile = libsMappingFile.ioFile
if (mappingFile.exists()) {
try {
libsMapping.loadFrom(mappingFile)
} catch (e: Exception) {
fileOperations.delete(mappingFile)
throw e
}
logger.debug("Loaded libs mapping from $mappingFile")
}
}
override fun saveStateAfterFinish() {
val mappingFile = libsMappingFile.ioFile
libsMapping.saveTo(mappingFile)
logger.debug("Saved libs mapping to $mappingFile")
}
private fun setInfoPlistValues(plist: InfoPlistBuilder) {
check(currentOS == OS.MacOS) { "Current OS is not macOS: $currentOS" }
plist[PlistKeys.LSMinimumSystemVersion] = "10.13"
plist[PlistKeys.CFBundleDevelopmentRegion] = "English"
plist[PlistKeys.CFBundleAllowMixedLocalizations] = "true"
val packageName = packageName.get()
plist[PlistKeys.CFBundleExecutable] = packageName
plist[PlistKeys.CFBundleIconFile] = "$packageName.icns"
val bundleId = nonValidatedMacBundleID.orNull
?: launcherMainClass.get().substringBeforeLast(".")
plist[PlistKeys.CFBundleIdentifier] = bundleId
plist[PlistKeys.CFBundleInfoDictionaryVersion] = "6.0"
plist[PlistKeys.CFBundleName] = packageName
plist[PlistKeys.CFBundlePackageType] = "APPL"
val packageVersion = packageVersion.get()!!
plist[PlistKeys.CFBundleShortVersionString] = packageVersion
// If building for the App Store, use "utilities" as default just like jpackage.
val category = macAppCategory.orNull ?: (if (macAppStore.orNull == true) "public.app-category.utilities" else null)
plist[PlistKeys.LSApplicationCategoryType] = category ?: "Unknown"
val packageBuildVersion = packageBuildVersion.orNull ?: packageVersion
plist[PlistKeys.CFBundleVersion] = packageBuildVersion
val year = Calendar.getInstance().get(Calendar.YEAR)
plist[PlistKeys.NSHumanReadableCopyright] = packageCopyright.orNull
?: "Copyright (C) $year"
plist[PlistKeys.NSSupportsAutomaticGraphicsSwitching] = "true"
plist[PlistKeys.NSHighResolutionCapable] = "true"
}
}
// Serializable is only needed to avoid breaking configuration cache:
// https://docs.gradle.org/current/userguide/configuration_cache.html#config_cache:requirements
private class FilesMapping : Serializable {
private var mapping = HashMap<File, List<File>>()
operator fun get(key: File): List<File>? =
mapping[key]
operator fun set(key: File, value: List<File>) {
mapping[key] = value
}
fun remove(key: File): List<File>? =
mapping.remove(key)
fun loadFrom(mappingFile: File) {
mappingFile.readLines().forEach { line ->
if (line.isNotBlank()) {
val paths = line.splitToSequence(File.pathSeparatorChar)
val lib = File(paths.first())
val mappedFiles = paths.drop(1).mapTo(ArrayList()) { File(it) }
mapping[lib] = mappedFiles
}
}
}
fun saveTo(mappingFile: File) {
mappingFile.parentFile.mkdirs()
mappingFile.bufferedWriter().use { writer ->
mapping.entries
.sortedBy { (k, _) -> k.absolutePath }
.forEach { (k, values) ->
(sequenceOf(k) + values.asSequence())
.joinTo(writer, separator = File.pathSeparator, transform = { it.absolutePath })
}
}
}
private fun writeObject(stream: ObjectOutputStream) {
stream.writeObject(mapping)
}
private fun readObject(stream: ObjectInputStream) {
mapping = stream.readObject() as HashMap<File, List<File>>
}
}
private fun isSkikoForCurrentOS(lib: File): Boolean =
lib.name.startsWith("skiko-awt-runtime-${currentOS.id}-${currentArch.id}")
&& lib.name.endsWith(".jar")
private fun unpackSkikoForCurrentOS(sourceJar: File, skikoDir: File, fileOperations: FileOperations): List<File> {
val entriesToUnpack = when (currentOS) {
OS.MacOS -> setOf("libskiko-macos-${currentArch.id}.dylib")
OS.Windows -> setOf("skiko-windows-${currentArch.id}.dll", "icudtl.dat")
OS.Linux -> setOf("libskiko-linux-${currentArch.id}.so")
}
// output files: unpacked libs, corresponding .sha256 files, and target jar
val outputFiles = ArrayList<File>(entriesToUnpack.size * 2 + 1)
val targetJar = skikoDir.resolve(sourceJar.name)
outputFiles.add(targetJar)
fileOperations.delete(skikoDir)
fileOperations.mkdir(skikoDir)
transformJar(sourceJar, targetJar) { entry, zin, zout ->
// check both entry or entry.sha256
if (entry.name.removeSuffix(".sha256") in entriesToUnpack) {
val unpackedFile = skikoDir.resolve(entry.name.substringAfterLast("/"))
zin.copyTo(unpackedFile)
outputFiles.add(unpackedFile)
} else {
copyZipEntry(entry, zin, zout)
}
}
return outputFiles
}