diff --git a/buildSrc/src/main/kotlin/korlibs/root/RootKorlibsPlugin.kt b/buildSrc/src/main/kotlin/korlibs/root/RootKorlibsPlugin.kt index cb0adce6bc..a3134c767b 100644 --- a/buildSrc/src/main/kotlin/korlibs/root/RootKorlibsPlugin.kt +++ b/buildSrc/src/main/kotlin/korlibs/root/RootKorlibsPlugin.kt @@ -143,27 +143,6 @@ object RootKorlibsPlugin { } fun Project.initSymlinkTrees() { - //fileTree(new File(rootProject.projectDir, "buildSrc/src/main/kotlinShared")) - //copy { - project.symlinktree( - fromFolder = File(rootProject.projectDir, "buildSrc/src/main/kotlin"), - intoFolder = File(rootProject.projectDir, "korge-gradle-plugin/build/srcgen2") - ) - - project.symlinktree( - fromFolder = File(rootProject.projectDir, "buildSrc/src/test/kotlin"), - intoFolder = File(rootProject.projectDir, "korge-gradle-plugin/build/testgen2") - ) - - project.symlinktree( - fromFolder = File(rootProject.projectDir, "buildSrc/src/main/resources"), - intoFolder = File(rootProject.projectDir, "korge-gradle-plugin/build/srcgen2res") - ) - - project.symlinktree( - fromFolder = File(rootProject.projectDir, "buildSrc/src/test/resources"), - intoFolder = File(rootProject.projectDir, "korge-gradle-plugin/build/testgen2res") - ) } fun Project.initShowSystemInfoWhenLinkingInWindows() { diff --git a/korge-gradle-plugin/build.gradle.kts b/korge-gradle-plugin/build.gradle.kts index a1247d1286..28cada4315 100644 --- a/korge-gradle-plugin/build.gradle.kts +++ b/korge-gradle-plugin/build.gradle.kts @@ -93,16 +93,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class).all { } kotlin.sourceSets.main.configure { - kotlin.srcDirs(File(projectDir, "build/srcgen"), File(projectDir, "build/srcgen2")) -} -kotlin.sourceSets.test.configure { - kotlin.srcDirs(File(projectDir, "build/testgen2")) -} -java.sourceSets.main.configure { - resources.srcDirs(File(projectDir, "build/srcgen2res")) -} -java.sourceSets.test.configure { - resources.srcDirs(File(projectDir, "build/testgen2res")) + kotlin.srcDirs(File(projectDir, "build/srcgen")) } dependencies { diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/NativeTools.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/NativeTools.kt new file mode 100644 index 0000000000..61d3322a36 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/NativeTools.kt @@ -0,0 +1,41 @@ +package korlibs + +import korlibs.korge.gradle.targets.* +import korlibs.modules.* +import org.gradle.api.* + +object NativeTools { + @JvmStatic + fun configureAllCInterop(project: Project, name: String) { + if (supportKotlinNative) { + project.kotlin { + for (target in allNativeTargets(project)) { + target.compilations["main"].cinterops { + it.maybeCreate(name) + } + } + } + } + } + + @JvmStatic + fun configureAndroidDependency(project: Project, dep: Any) { + project.dependencies { + project.afterEvaluate { + if (project.configurations.findByName("androidMainApi") != null) { + add("androidMainApi", dep) + } + } + } + } + + @JvmStatic + fun groovyConfigurePublishing(project: Project, multiplatform: Boolean) { + project.configurePublishing(multiplatform = multiplatform) + } + + @JvmStatic + fun groovyConfigureSigning(project: Project) { + project.configureSigning() + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/GameCategory.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/GameCategory.kt new file mode 100644 index 0000000000..85d39bf899 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/GameCategory.kt @@ -0,0 +1,13 @@ +package korlibs.korge.gradle + +enum class GameCategory { + ACTION, ADVENTURE, ARCADE, BOARD, CARD, + CASINO, DICE, EDUCATIONAL, FAMILY, KIDS, + MUSIC, PUZZLE, RACING, ROLE_PLAYING, SIMULATION, + SPORTS, STRATEGY, TRIVIA, WORD; + + companion object { + val VALUES: Map = GameCategory.values().toList().associateBy { it.name.uppercase() } + operator fun get(key: String): GameCategory? = VALUES[key.uppercase().trim()] + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeExtension.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeExtension.kt new file mode 100644 index 0000000000..ae6d0fa097 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeExtension.kt @@ -0,0 +1,741 @@ +package korlibs.korge.gradle + +import groovy.text.* +import korlibs.* +import korlibs.korge.gradle.processor.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.android.* +import korlibs.korge.gradle.targets.ios.* +import korlibs.korge.gradle.targets.js.* +import korlibs.korge.gradle.targets.jvm.* +import korlibs.korge.gradle.util.* +import korlibs.modules.* +import korlibs.root.* +import org.gradle.api.* +import org.gradle.api.artifacts.* +import org.gradle.api.logging.* +import org.gradle.internal.impldep.org.yaml.snakeyaml.Yaml +import org.jetbrains.kotlin.gradle.plugin.mpp.* +import java.io.* +import java.net.* +import java.time.* +import javax.inject.* +import javax.naming.* + +enum class Orientation(val lc: String) { DEFAULT("default"), LANDSCAPE("landscape"), PORTRAIT("portrait") } +enum class DisplayCutout(val lc: String) { DEFAULT("default"), SHORT_EDGES("shortEdges"), NEVER("never"), ALWAYS("always") } + +class KorgePluginsContainer(val project: Project, val parentClassLoader: ClassLoader = KorgePluginsContainer::class.java.classLoader) { + val globalParams = LinkedHashMap() + val plugins = LinkedHashMap() + + val files by lazy { project.resolveArtifacts(*plugins.values.map { it.jvmArtifact }.toTypedArray()) } + val urls by lazy { files.map { it.toURI().toURL() } } + val classLoader by lazy { + //println("KorgePluginsContainer.classLoader: $urls") + URLClassLoader(urls.toTypedArray(), parentClassLoader) + } + + val pluginExts = KorgePluginExtensions(project) + + fun addPlugin(artifact: MavenLocation): KorgePluginDescriptor { + return plugins.getOrPut(artifact) { KorgePluginDescriptor(this, artifact) } + } +} + +data class KorgePluginDescriptor(val container: KorgePluginsContainer, val artifact: MavenLocation, val args: LinkedHashMap = LinkedHashMap()) { + val jvmArtifact = artifact.withNameSuffix("-jvm") + val files by lazy { container.project.resolveArtifacts(jvmArtifact) } + val urls by lazy { files.map { it.toURI().toURL() } } + val classLoader by lazy { URLClassLoader(urls.toTypedArray(), container.parentClassLoader) } + fun addArgs(args: Map) = this.apply { this.args.putAll(args) }.apply { container.globalParams.putAll(args) } +} + +fun String.replaceGroovy(replacements: Map): String { + //println("String.replaceGroovy: this=$this, replacements=$replacements") + val templateEngine = SimpleTemplateEngine() + val template = templateEngine.createTemplate(this) + val replaced = template.make(replacements.toMutableMap()) + return replaced.toString() +} + +data class MavenLocation(val group: String, val name: String, val version: String, val classifier: String? = null) { + val versionWithClassifier by lazy { buildString { + append(version) + if (classifier != null) { + append(':') + append(classifier) + } + } } + + companion object { + operator fun invoke(location: String): MavenLocation { + val parts = location.split(":") + return MavenLocation(parts[0], parts[1], parts[2], parts.getOrNull(3)) + } + } + + fun withNameSuffix(suffix: String) = copy(name = "$name$suffix") + + val full: String by lazy { "$group:$name:$versionWithClassifier" } + + override fun toString(): String = full +} + +@Suppress("unused") +open class KorgeExtension( + @Inject val project: Project, + //private val objectFactory: ObjectFactory +) { + private var includeIndirectAndroid: Boolean = false + internal fun init(includeIndirectAndroid: Boolean, projectType: ProjectType) { + this.includeIndirectAndroid = includeIndirectAndroid + this.projectType = projectType + this.project.afterEvaluate { + implicitCheckVersion() + } + } + + companion object { + const val ESBUILD_DEFAULT_VERSION = "0.21.5" + + val DEFAULT_ANDROID_EXCLUDE_PATTERNS = setOf( + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/license.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/notice.txt", + "META-INF/LGPL*", + "META-INF/AL2.0", + "**/androidsupportmultidexversion.txt", + "META-INF/versions/9/previous-compilation-data.bin", + ) + + val DEFAULT_ANDROID_INCLUDE_PATTERNS_LIBS = setOf( + "META-INF/*.kotlin_module", + "**/*.kotlin_metadata", + "**/*.kotlin_builtins", + ) + + val validIdentifierRegexp = Regex("^[a-zA-Z_]\\w*$") + + fun isIdValid(id: String) = id.isNotEmpty() && id.isNotBlank() && id.split(".").all { it.matches(validIdentifierRegexp) } + + fun verifyId(id: String) { + if (!isIdValid(id)) { + throw InvalidNameException("'$id' is invalid. Should be separed by '.', shouldn't have spaces, and each component should not start by a number. Example: 'com.test.demo2'") + } + } + } + + internal var targets = LinkedHashSet() + + private fun target(name: String, block: () -> Unit) { + if (!targets.contains(name)) { + targets.add(name) + block() + } + } + + lateinit var projectType: ProjectType + private set + + private var _checkVersionOnce = false + + var autoGenerateTypedResources: Boolean = true + + /** + * This checks that you are using the latest version of KorGE + * once per day. + * + * This downloads the latest version and + * optionally when [telemetry] is set to true, or gradle property `korge.disable.telemetry` is set to true + * sends your current KorGE version, operating system, cpu architecture and a random, anonymous install UUID + * for statistical purposes. + * + * In the case you want the plugin to also notify you that a new version is available, + * you can set the [report] parameter to true. + * + * If you want to totally disable this check, you can by setting [check] to false. + * + * Have in mind that this check is a single small GET request once per day, it is anonymous, + * doesn't send any kind of personal data, and it greatly helps us to have statistics of how many people is using KorGE + * and which version. If you don't want to have the report once per day you can set report=false + */ + fun checkVersion(check: Boolean = true, report: Boolean = true, telemetry: Boolean = project.findProperty("korge.disable.telemetry") != "true") { + if (!_checkVersionOnce) { + _checkVersionOnce = true + if (check) { + val thread = project.korgeCheckVersion(report, telemetry) + project.afterEvaluate { thread.join() } + } + } + } + + fun loadYaml(file: File) { + val korgeYamlString = file.takeIfExists()?.readText() ?: return + try { + val info = korlibs.korge.gradle.util.Yaml.read(korgeYamlString).dyn + info["id"].toStringOrNull()?.let { this.id = it } + + author( + name = info["author"]["name"].str, + email = info["author"]["email"].str, + href = info["author"]["href"].str, + ) + + gameCategory = GameCategory[info["category"].str] + + info["icon"].toStringOrNull()?.also { + icon = project.file(it) + } + + info["banner"].toStringOrNull()?.also { + banner = project.file(it) + } + + val targetList = info["targets"].list + if (targetList.isEmpty()) { + targetDefault() + } else { + for (target in targetList) { + when (target.str) { + "all" -> targetAll() + "default" -> targetDefault() + "jvm" -> targetJvm() + "js" -> targetJs() + "wasm", "wasmJs" -> targetWasmJs() + "android" -> targetAndroid() + "ios" -> targetIos() + else -> project.logger.log(LogLevel.WARN, "Unknown target in korge.yaml: '${target.str}'") + } + } + } + + for (plugin in info["plugins"].list) { + val pluginStr = plugin.str + when (pluginStr) { + "\$kotlin.serialization" -> serialization() + "\$kotlin.serialization.json" -> serializationJson() + else -> project.logger.log(LogLevel.WARN, "Unknown plugin in korge.yaml: '${pluginStr}'") + } + } + + for ((key, value) in info["config"].map) { + config(key.str, value.str) + } + + for ((name, jvmMainClassName) in info["entrypoints"].map) { + entrypoint(name.str, jvmMainClassName.str) + } + + // @TODO: Implement the rest of the properties including targets etc. + } catch (e: Throwable) { + e.printStackTrace() + } + } + + internal fun implicitCheckVersion() { + checkVersion(check = true, report = false) + } + + /** + * Configures JVM target + */ + fun targetJvm() { + target("jvm") { + project.configureJvm(projectType) + } + } + + /** + * Configures JavaScript target + */ + fun targetJs() { + target("js") { + project.configureJavaScript(projectType) + } + } + + @Deprecated("Use targetWasmJs instead", ReplaceWith("targetWasmJs(binaryen)")) + fun targetWasm(binaryen: Boolean = false) { + targetWasmJs(binaryen) + } + + /** + * Configures WASM target + */ + fun targetWasmJs(binaryen: Boolean = false) { + if (korlibs.korge.gradle.targets.wasm.isWasmEnabled(project)) { + target("wasmJs") { + project.configureWasm(projectType, binaryen) + } + } + } + + /** + * Deprecated. Used to create K/N desktop executables. + */ + @Deprecated("") + fun targetDesktop() { + //println("targetDesktop is deprecated") + } + + /** + * Deprecated. Used to create K/N desktop executables for other platforms. + */ + @Deprecated("") + fun targetDesktopCross() { + //println("targetDesktopCross is deprecated") + } + + /** + * Configures Android indirect. Alias for [targetAndroidIndirect] + */ + fun targetAndroid() { + target("android") { + project.configureAndroidDirect(projectType, isKorge = true) + } + } + + @Deprecated("", ReplaceWith("targetAndroid()")) fun targetAndroidIndirect() = targetAndroid() + @Deprecated("", ReplaceWith("targetAndroid()")) fun targetAndroidDirect() = targetAndroid() + + /** + * Configures Kotlin/Native iOS target (only on macOS) + */ + fun targetIos() { + target("ios") { + if (supportKotlinNative) { + project.configureNativeIos(projectType) + } + } + } + + /** + * Uses gradle.properties and system environment variables to determine which targets to enable. JVM is always enabled. + * + * gradle.properties: + * - korge.enable.desktop=true + * - korge.enable.android=true + * - korge.enable.ios=true + * - korge.enable.js=true + * + * Environment Variables: + * - KORGE_ENABLE_DESKTOP + * - KORGE_ENABLE_ANDROID + * - KORGE_ENABLE_ANDROID_IOS + * - KORGE_ENABLE_ANDROID_JS + */ + fun targetDefault() { + if (newDesktopEnabled) targetDesktop() + if (newAndroidEnabled) targetAndroid() + //if (newAndroidIndirectEnabled) targetAndroidIndirect() + //if (newAndroidDirectEnabled) targetAndroidDirect() + if (newIosEnabled) targetIos() + if (newJsEnabled) targetJs() + } + + /** + * Configure all the available targets unconditionally. + */ + fun targetAll() { + targetJvm() + targetJs() + targetWasmJs() + targetDesktop() + targetAndroid() + targetIos() + } + + /** Enables kotlinx.serialization */ + fun serialization() { + project.plugins.apply("kotlinx-serialization") + androidGradlePlugin("kotlinx-serialization") + androidGradleClasspath("org.jetbrains.kotlin:kotlin-serialization:${BuildVersions.KOTLIN}") + } + + /** Enables kotlinx.serialization and includes `org.jetbrains.kotlinx:kotlinx-serialization-json` */ + fun serializationJson() { + serialization() + project.dependencies.add("commonMainApi", "org.jetbrains.kotlinx:kotlinx-serialization-json:${BuildVersions.KOTLIN_SERIALIZATION}") + androidGradleDependency("org.jetbrains.kotlinx:kotlinx-serialization-json:${BuildVersions.KOTLIN_SERIALIZATION}") + } + + val resourceProcessors = arrayListOf() + + fun addResourceProcessor(processor: KorgeResourceProcessor) { + resourceProcessors += processor + } + + //val bundles = KorgeBundles(project) + + //@JvmOverloads + //fun bundle(uri: String, baseName: String? = null) = bundles.bundle(uri, baseName) + + val DEFAULT_JVM_TARGET = GRADLE_JAVA_VERSION_STR + var jvmTarget: String = project.findProject("jvm.target")?.toString() ?: DEFAULT_JVM_TARGET + var androidLibrary: Boolean = project.findProperty("android.library") == "true" + var overwriteAndroidFiles: Boolean = project.findProperty("overwrite.android.files") == "false" + var id: String = "com.unknown.unknownapp" + get() = field + set(value) { + verifyId(value) + field = value + } + var versionCode: Int = 1 + var version: String = "0.0.1" + var preferredIphoneSimulatorVersion: Int = 8 + + var exeBaseName: String = "app" + + var name: String = "unnamed" + var title: String? = null + var description: String = "description" + var orientation: Orientation = Orientation.DEFAULT + var displayCutout: DisplayCutout = DisplayCutout.DEFAULT + + var copyright: String = "Copyright (c) ${Year.now().value} Unknown" + + var sourceMaps: Boolean = false + var supressWarnings: Boolean = false + + val versionSubstitutions = LinkedHashMap().also { + it["${RootKorlibsPlugin.KORGE_GROUP}:korge"] = BuildVersions.KORGE + it["${RootKorlibsPlugin.KORGE_GROUP}:korge-root"] = BuildVersions.KORGE + it["${RootKorlibsPlugin.KORGE_GROUP}:korge-core"] = BuildVersions.KORGE + it["${RootKorlibsPlugin.KORGE_GROUP}:korge-platform"] = BuildVersions.KORGE + it["${RootKorlibsPlugin.KORGE_RELOAD_AGENT_GROUP}:korge-reload-agent"] = BuildVersions.KORGE + it["${RootKorlibsPlugin.KORGE_GRADLE_PLUGIN_GROUP}:korge-gradle-plugin"] = BuildVersions.KORGE + } + + val artifactSubstitution = LinkedHashMap().also { + val korgeArtifact = "${RootKorlibsPlugin.KORGE_GROUP}:korge:${BuildVersions.KORGE}" + val korgeFoundationArtifact = "${RootKorlibsPlugin.KORGE_GROUP}:korge-foundation:${BuildVersions.KORGE}" + val korgeCoreArtifact = "${RootKorlibsPlugin.KORGE_GROUP}:korge-core:${BuildVersions.KORGE}" + + it["com.soywiz.korlibs.korge2:korge"] = korgeArtifact + it["com.soywiz.korlibs.korgw:korgw"] = korgeArtifact + + it["com.soywiz.korlibs.kbignum:kbignum"] = korgeFoundationArtifact + it["com.soywiz.korlibs.kds:kds"] = korgeFoundationArtifact + it["com.soywiz.korlibs.korinject:korinject"] = korgeFoundationArtifact + it["com.soywiz.korlibs.krypto:krypto"] = korgeFoundationArtifact + it["com.soywiz.korlibs.korma:korma"] = korgeFoundationArtifact + it["com.soywiz.korlibs.kmem:kmem"] = korgeFoundationArtifact + it["com.soywiz.korlibs.klock:klock"] = korgeFoundationArtifact + + it["com.soywiz.korlibs.korio:korio"] = korgeCoreArtifact + it["com.soywiz.korlibs.korim:korim"] = korgeCoreArtifact + it["com.soywiz.korlibs.korau:korau"] = korgeCoreArtifact + it["com.soywiz.korlibs.korte:korte"] = korgeCoreArtifact + } + + fun versionSubstitution(groupName: String, version: String) { + versionSubstitutions[groupName] = version + } + + fun artifactSubstitution(groupName: String, newGroupNameVersion: String) { + artifactSubstitution[groupName] = newGroupNameVersion + } + + /** + * Determines whether the standard console will be available on Windows or not + * by setting the windows subsystem to console or windows. + * + * When set to null, on debug builds it will include open console, and on release builds it won't open any console. + */ + var enableConsole: Boolean? = null + + var authorName = "unknown" + var authorEmail = "unknown@unknown" + var authorHref = "http://localhost" + + val nativeEnabled = supportKotlinNative + + val newDesktopEnabled get() = project.findProperty("korge.enable.desktop") == "true" || System.getenv("KORGE_ENABLE_DESKTOP") == "true" + val newAndroidEnabled get() = project.findProperty("korge.enable.android") == "true" || System.getenv("KORGE_ENABLE_ANDROID") == "true" + //val newAndroidIndirectEnabled get() = project.findProperty("korge.enable.android.indirect") == "true" || System.getenv("KORGE_ENABLE_ANDROID_INDIRECT") == "true" + //val newAndroidDirectEnabled get() = project.findProperty("korge.enable.android.direct") == "true" || System.getenv("KORGE_ENABLE_ANDROID_DIRECT") == "true" + val newIosEnabled get() = project.findProperty("korge.enable.ios") == "true" || System.getenv("KORGE_ENABLE_IOS") == "true" + val newJsEnabled get() = project.findProperty("korge.enable.js") == "true" || System.getenv("KORGE_ENABLE_JS") == "true" + + var searchResourceProcessorsInMainSourceSet: Boolean = false + + var skipDeps: Boolean = false + var icon: File? = File(project.projectDir, "icon.png") + var banner: File? = File(project.projectDir, "banner.png") + + var javaAddOpens: List = JvmAddOpens.createAddOpens().toMutableList() + + var gameCategory: GameCategory? = null + + var fullscreen: Boolean? = null + + var backgroundColor: Int = 0xff000000.toInt() + + var webBindAddress = project.findProperty("web.bind.address")?.toString() ?: "0.0.0.0" + var webBindPort = project.findProperty("web.bind.port")?.toString()?.toIntOrNull() ?: 0 + + var appleDevelopmentTeamId: String? = java.lang.System.getenv("DEVELOPMENT_TEAM") + ?: java.lang.System.getProperty("appleDevelopmentTeamId")?.toString() + ?: project.findProperty("appleDevelopmentTeamId")?.toString() + + var appleOrganizationName = "User Name Name" + + var entryPoint: String? = null + //val jvmMainClassNameProp: Property = objectFactory.property(String::class.java).also { it.set("MainKt") } + var jvmMainClassName: String = "MainKt" + //get() = jvmMainClassNameProp.get() + //set(value) { jvmMainClassNameProp.set(value) } + //var proguardObfuscate: Boolean = false + var proguardObfuscate: Boolean = true + + val realEntryPoint: String get() = entryPoint ?: (jvmMainClassName.substringBeforeLast('.', "") + ".main").trimStart('.') + val realJvmMainClassName: String get() = jvmMainClassName + + val extraEntryPoints = arrayListOf() + + internal fun getDefaultEntryPoint(): Entrypoint { + return Entrypoint("") { realJvmMainClassName } + } + + internal fun getAllEntryPoints(): List { + return listOf(getDefaultEntryPoint()) + extraEntryPoints + } + + class Entrypoint(val name: String, val jvmMainClassName: () -> String) { + val entryPoint by lazy { (jvmMainClassName().substringBeforeLast('.', "") + ".main").trimStart('.') } + } + + fun entrypoint(name: String, jvmMainClassName: String) { + extraEntryPoints.add(Entrypoint(name) { jvmMainClassName }) + } + + var esbuildVersion: String = ESBUILD_DEFAULT_VERSION + + var androidMinSdk: Int = ANDROID_DEFAULT_MIN_SDK + var androidCompileSdk: Int = ANDROID_DEFAULT_COMPILE_SDK + var androidTargetSdk: Int = ANDROID_DEFAULT_TARGET_SDK + + var androidTimeoutMs: Int = 30 * 1000 + + var androidExcludePatterns: Set = DEFAULT_ANDROID_EXCLUDE_PATTERNS + + fun androidSdk(compileSdk: Int, minSdk: Int, targetSdk: Int) { + androidMinSdk = minSdk + androidCompileSdk = compileSdk + androidTargetSdk = targetSdk + } + + internal var _androidAppendBuildGradle: String? = null + + fun androidAppendBuildGradle(str: String) { + if (_androidAppendBuildGradle == null) { + _androidAppendBuildGradle = "" + } + _androidAppendBuildGradle += str + } + + val configs = LinkedHashMap() + + fun config(name: String, value: String) { + configs[name] = value + } + + val plugins = KorgePluginsContainer(project) + val androidGradlePlugins = LinkedHashSet() + val androidGradleDependencies = LinkedHashSet() + val androidGradleClasspaths = LinkedHashSet() + val androidManifestApplicationChunks = LinkedHashSet() + val androidManifestChunks = LinkedHashSet() + val androidCustomApplicationAttributes = LinkedHashMap() + var androidMsaa: Int? = null + + fun plugin(name: String, args: Map = mapOf()) { + dependencyMulti(name, registerPlugin = false) + plugins.addPlugin(MavenLocation(name)).addArgs(args) + } + + internal val defaultPluginsClassLoader by lazy { plugins.classLoader } + + var androidReleaseSignStoreFile: String = "build/korge.keystore" + var androidReleaseSignStorePassword: String = "password" + var androidReleaseSignKeyAlias: String = "korge" + var androidReleaseSignKeyPassword: String = "password" + + var iosDevelopmentTeam: String? = null + + // Already included in core + fun supportExperimental3d() = Unit + fun support3d() = Unit + + // + fun androidManifestApplicationChunk(text: String) { + androidManifestApplicationChunks += text + } + + /** For example: androidCustomApplicationAttribute("android:usesCleartextTraffic", "true") */ + fun androidCustomApplicationAttribute(key: String, value: String) { + androidCustomApplicationAttributes[key] = value + } + + fun androidGradlePlugin(name: String) { + androidGradlePlugins += name + } + + fun androidGradleClasspath(name: String) { + androidGradleClasspaths += name + } + + fun androidGradleDependency(dependency: String) { + androidGradleDependencies += dependency + } + + fun androidManifestChunk(text: String) { + androidManifestChunks += text + } + + fun androidPermission(name: String) { + androidManifestChunk("""""") + } + + fun supportVibration() { + androidPermission("android.permission.VIBRATE") + } + + //@Deprecated("") + //fun admob(ADMOB_APP_ID: String) { + // bundle("https://github.com/korlibs/korge-bundles.git::korge-admob::4ac7fcee689e1b541849cedd1e017016128624b9##2ca2bf24ab19e4618077f57092abfc8c5c8fba50b2797a9c6d0e139cd24d8b35") + // config("ADMOB_APP_ID", ADMOB_APP_ID) + //} + // + //@Deprecated("") + //fun gameServices() { + // bundle("https://github.com/korlibs/korge-bundles.git::korge-services::4ac7fcee689e1b541849cedd1e017016128624b9##392d5ed87428c7137ae40aa7a44f013dd1d759630dca64e151bbc546eb25e28e") + //} + // + //@Deprecated("") + //fun billing() { + // bundle("https://github.com/korlibs/korge-bundles.git::korge-billing::4ac7fcee689e1b541849cedd1e017016128624b9##cbde3d386e8d792855b7ef64e5e700f43b7bb367aedc6a27198892e41d50844b") + //} + + fun author(name: String, email: String, href: String) { + authorName = name + authorEmail = email + authorHref = href + } + + ///////////////////////////////////////////////// + ///////////////////////////////////////////////// + + + fun dependencyProject(name: String) = project { + dependencies { + add("commonMainApi", project(name)) + add("commonTestImplementation", project(name)) + } + } + + val ALL_NATIVE_TARGETS by lazy { listOf("iosArm64", "iosX64", "iosSimulatorArm64") } + //val ALL_TARGETS = listOf("android", "js", "jvm", "metadata") + ALL_NATIVE_TARGETS + val ALL_TARGETS by lazy { listOf("js", "jvm", "metadata") + ALL_NATIVE_TARGETS } + + @JvmOverloads + fun dependencyMulti(group: String, name: String, version: String, targets: List = ALL_TARGETS, suffixCommonRename: Boolean = false, androidIsJvm: Boolean = false): Unit = project { + project.dependencies { + //println("dependencyMulti --> $group:$name:$version") + add("commonMainApi", "$group:$name:$version") + } + Unit + } + + @JvmOverloads + fun dependencyMulti(dependency: String, targets: List = ALL_TARGETS, registerPlugin: Boolean = true) { + val location = MavenLocation(dependency) + if (registerPlugin) plugin(location.full) + return dependencyMulti(location.group, location.name, location.versionWithClassifier, targets) + } + + /* + @JvmOverloads + fun dependencyNodeModule(name: String, version: String) = project { + val node = extensions.getByType(NodeExtension::class.java) + + val installNodeModule = tasks.createThis("installJs${name.capitalize()}") { + onlyIf { !File(node.nodeModulesDir, name).exists() } + setArgs(arrayListOf("install", "$name@$version")) + } + + tasks.getByName("jsTestNode").dependsOn(installNodeModule) + } + */ + + data class CInteropTargets(val name: String, val targets: List) + + val cinterops = arrayListOf() + + + fun dependencyCInterops(name: String, targets: List) = project { + cinterops += CInteropTargets(name, targets) + for (target in targets) { + ((kotlin.targets.findByName(target) as AbstractKotlinTarget).compilations["main"] as KotlinNativeCompilation).apply { + cinterops.apply { + maybeCreate(name).apply { + } + } + } + } + } + + @JvmOverloads + fun dependencyCInteropsExternal(dependency: String, cinterop: String, targets: List = ALL_NATIVE_TARGETS) { + dependencyMulti("$dependency:cinterop-$cinterop@klib", targets) + } + + fun addDependency(config: String, notation: String) { + val cfg = project.configurations.findByName(config) + if (cfg == null) { + // @TODO: Turkish hack. This doesn't seems right. Probably someone messed something up. + if (config.endsWith("Implementation")) { + val config2 = config.removeSuffix("Implementation") + "İmplementation" + println("Can't find config: $config . Trying: $config2 (Turkish hack)") + return addDependency(config2, notation) + } + + for (rcfg in project.configurations) { + println("CONFIGURATION: ${rcfg.name}") + } + error("Can't find configuration '$config'") + } + project.dependencies.add(config, notation) + } + + fun finish() { + if (!skipDeps && project.allprojects.any { it.path == ":deps" }) { + project.dependencies { + add("commonMainApi", project.project(":deps")) + //add("commonMainApi", project(":korge-dragonbones")) + } + } + } +} + +// println(project.resolveArtifacts("korlibs.korge:korge-metadata:1.0.0")) +fun Project.resolveArtifacts(vararg artifacts: String): Set { + val config = project.configurations.detachedConfiguration( + *artifacts.map { + (project.dependencies.create(it) as ExternalModuleDependency).apply { + targetConfiguration = "default" + } + }.toTypedArray() + ).apply { + isTransitive = false + } + return config.files +} + +fun Project.resolveArtifacts(vararg artifacts: MavenLocation): Set = + resolveArtifacts(*artifacts.map { it.full }.toTypedArray()) diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeGradlePlugin.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeGradlePlugin.kt new file mode 100644 index 0000000000..df10a692ee --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeGradlePlugin.kt @@ -0,0 +1,297 @@ +package korlibs.korge.gradle + +import korlibs.* +import korlibs.korge.gradle.module.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.all.* +import korlibs.korge.gradle.targets.jvm.* +import korlibs.korge.gradle.targets.linux.LDLibraries +import korlibs.korge.gradle.typedresources.* +import korlibs.korge.gradle.util.* +import korlibs.modules.* +import korlibs.root.* +import org.gradle.api.* +import org.gradle.api.plugins.* +import org.gradle.api.tasks.* +import org.gradle.api.tasks.diagnostics.* +import org.gradle.internal.classloader.* +import org.jetbrains.kotlin.gradle.dsl.* +import java.io.* +import java.net.* +import java.util.* +import java.util.concurrent.* +import kotlin.concurrent.* + +class KorgeGradleApply(val project: Project, val projectType: ProjectType) { + fun apply(includeIndirectAndroid: Boolean = true) = project { + checkMinimumJavaVersion() + // @TODO: Doing this disables the ability to use configuration cache + //System.setProperty("java.awt.headless", "true") + + val currentGradleVersion = SemVer(project.gradle.gradleVersion) + //val expectedGradleVersion = SemVer("6.8.1") + val expectedGradleVersion = SemVer("7.5.0") + val korgeCheckGradleVersion = (project.ext.properties["korgeCheckGradleVersion"] as? Boolean) ?: true + + if (korgeCheckGradleVersion && currentGradleVersion < expectedGradleVersion) { + error("Korge requires at least Gradle $expectedGradleVersion, but running on Gradle $currentGradleVersion. Please, edit gradle/wrapper/gradle-wrapper.properties") + } + + if (isLinux) { + project.logger.info("LD folders: ${LDLibraries.ldFolders}") + for (lib in listOf("libGL.so.1")) { + if (!LDLibraries.hasLibrary(lib)) { + System.err.println("Can't find $lib. Please: sudo apt-get -y install freeglut3") + } + } + } + + logger.info("Korge Gradle plugin: ${BuildVersions.ALL}, projectType=$projectType") + + KorgeVersionsTask.registerShowKorgeVersions(project) + + project.korge.init(includeIndirectAndroid, projectType) + + project.configureIdea() + project.addVersionExtension() + project.configureRepositories() + project.configureKotlin() + + korge.targetJvm() + + project.afterEvaluate { + project.configureDependencies() + project.addGenResourcesTasks() + project.enableFeaturesOnAllTargets() + + project.configureTests() + } + + project.configureTypedResourcesGenerator() + } + + private fun Project.configureDependencies() { + dependencies { + add("commonMainApi", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") + add("commonMainApi", "${RootKorlibsPlugin.KORGE_GROUP}:korge:${korgeVersion}") + //add("commonMainApi", "${RootKorlibsPlugin.KORGE_GROUP}:korge-core:${korgeVersion}") + //add("commonMainApi", "${RootKorlibsPlugin.KORGE_GROUP}:korge-platform:${korgeVersion}") + } + } + + private fun Project.addVersionExtension() { + ext.set("korgeVersion", korgeVersion) + ext.set("kotlinVersion", kotlinVersion) + ext.set("coroutinesVersion", coroutinesVersion) + } + + private fun Project.configureKotlin() { + plugins.applyOnce("kotlin-multiplatform") + + project.korge.addDependency("commonMainImplementation", "org.jetbrains.kotlin:kotlin-stdlib-common") + project.korge.addDependency("commonTestImplementation", "org.jetbrains.kotlin:kotlin-test") + + //gkotlin.sourceSets.maybeCreate("commonMain").dependencies { + //} + //kotlin.sourceSets.create("") + } +} + +fun Project.configureAutoVersions() { + val korlibsConfigureAutoVersions = "korlibsConfigureAutoVersions" + if (rootProject.extra.has(korlibsConfigureAutoVersions)) return + rootProject.extra.set(korlibsConfigureAutoVersions, true) + allprojectsThis { + configurations.all { + if (it.name == KORGE_RELOAD_AGENT_CONFIGURATION_NAME) return@all + + it.resolutionStrategy.eachDependency { details -> + //println("DETAILS: ${details.requested} : '${details.requested.group}' : '${details.requested.name}' : '${details.requested.version}'") + val groupWithName = "${details.requested.group}:${details.requested.name}" + if (details.requested.version.isNullOrBlank()) { + val version = korge.versionSubstitutions[groupWithName] + if (version != null) { + details.useVersion(version) + details.because("korge.versionSubstitutions: '$groupWithName' -> $version") + } + val artifact = korge.artifactSubstitution[groupWithName] + if (artifact != null) { + details.useTarget(artifact) + details.because("korge.artifactSubstitution: '$groupWithName' -> $artifact") + } + } + } + } + } +} + +fun Project.configureBuildScriptClasspathTasks() { + // https://gist.github.com/xconnecting/4037220 + val printBuildScriptClasspath = project.tasks.createThis("printBuildScriptClasspath") { + configurations = project.buildscript.configurations + } + val printBuildScriptClasspath2 = project.tasks.createThis("printBuildScriptClasspath2") { + doFirst { + fun getClassLoaderChain(classLoader: ClassLoader, out: ArrayList = arrayListOf()): List { + var current: ClassLoader? = classLoader + while (current != null) { + out.add(current) + current = current.parent + } + return out + } + + fun printClassLoader(classLoader: ClassLoader) { + when (classLoader) { + is URLClassLoader -> { + println(classLoader.urLs.joinToString("\n")) + } + is ClassLoaderHierarchy -> { + classLoader.visit(object : ClassLoaderVisitor() { + override fun visit(classLoader: ClassLoader) { + super.visit(classLoader) + } + + override fun visitSpec(spec: ClassLoaderSpec) { + super.visitSpec(spec) + } + + override fun visitClassPath(classPath: Array) { + classPath.forEach { println(it) } + } + + override fun visitParent(classLoader: ClassLoader) { + super.visitParent(classLoader) + } + }) + } + } + } + + println("Class loaders:") + val classLoaders = getClassLoaderChain(Thread.currentThread().contextClassLoader) + for (classLoader in classLoaders.reversed()) { + println(" - $classLoader") + } + + for (classLoader in classLoaders.reversed()) { + println("") + println("$classLoader:") + println("--------------") + printClassLoader(classLoader) + } + //println(ClassLoader.getSystemClassLoader()) + //println((Thread.currentThread().contextClassLoader as URLClassLoader).parent.urLs.joinToString("\n")) + //println((KorgeGradlePlugin::class.java.classLoader as URLClassLoader).urLs.joinToString("\n")) + } + } + +} + +val Project.gkotlin get() = properties["kotlin"] as KotlinMultiplatformExtension +val Project.ext get() = extensions.getByType(ExtraPropertiesExtension::class.java) + +fun Project.korge(callback: KorgeExtension.() -> Unit) = korge.apply(callback).also { it.finish() } +val Project.kotlin: KotlinMultiplatformExtension get() = this.extensions.getByType(KotlinMultiplatformExtension::class.java) +val Project.korge: KorgeExtension get() = extensionGetOrCreate("korge") + +inline fun Project.extensionGetOrCreate(name: String): T { + val extension = project.extensions.findByName(name) as? T? + return if (extension == null) { + val newExtension = project.extensions.create(name, T::class.java) + newExtension + } else { + extension + } +} + +open class JsWebCopy() : Copy() { + @OutputDirectory + open lateinit var targetDir: File +} + +private val korgeCacheData = ConcurrentHashMap() +val korgeCacheDir: File get() = File(System.getProperty("user.home"), ".korge").apply { if (!this.isDirectory) mkdirs() } + +var Project.korgeCacheData: ConcurrentHashMap by projectExtension { ConcurrentHashMap() } +var Project.korgeCacheDir: File by projectExtension { File(System.getProperty("user.home"), ".korge").apply { if (!this.isDirectory) mkdirs() } } +//val node_modules by lazy { project.file("node_modules") } + +val Project.korgeInstallUUID: String get() { + return korgeCacheData.getOrPut("korgeInstallUUID") { + val uuidFile = File(korgeCacheDir, "install-uuid") + if (!uuidFile.exists()) { + uuidFile.writeText(UUID.randomUUID().toString().replace('7', '1').replace('3', '9').replace('a', 'f')) + } + uuidFile.readText() + } +} + +fun Project.korgeVersionJson(telemetry: Boolean): String { + val DEFAULT_JSON = "{\"version\": \"${BuildVersions.KORGE}\", \"motd\": \"Fallback\"}" + return korgeCacheData.getOrPut("korgeVersionJson") { + val versionJsonFile = File(korgeCacheDir, "version.json") + if (!versionJsonFile.isFile && System.currentTimeMillis() - versionJsonFile.lastModified() >= 24 * 3600 * 1000L) { + val base = "https://version.korge.org/version.json?source=gradle" + val props: Map = mapOf( + "version" to BuildVersions.KORGE, + "install.uuid" to korgeInstallUUID, + "ci" to (System.getenv("CI") == "true").toString(), + "os.name" to System.getProperty("os.name"), + "os.arch" to System.getProperty("os.arch"), + "os.version" to System.getProperty("os.version"), + ) + fun Map.toQueryString(): String { + return this.map { + URLEncoder.encode(it.key, Charsets.UTF_8) + "=" + URLEncoder.encode(it.value, Charsets.UTF_8) + }.joinToString("&") + } + try { + downloadFile( + URL( + when (telemetry) { + true -> "$base&${props.toQueryString()}" + else -> base + } + + ), + versionJsonFile, + connectionTimeout = 5_000, + readTimeout = 3_000, + ) + } catch (e: Throwable) { + logger.info(e.stackTraceToString()) + versionJsonFile.writeText(DEFAULT_JSON) + } + } + try { versionJsonFile.readText() } catch (e: Throwable) { DEFAULT_JSON } + } +} + +fun Project.korgeCheckVersion(report: Boolean = true, telemetry: Boolean = true): Thread { + return thread(start = true, isDaemon = false) { + try { + val versionJson = Json.parse(korgeVersionJson(telemetry = telemetry)).dyn + val latestVersion = versionJson["version"].str + val motd = versionJson["motd"].str + + //println("versionJson=$versionJson") + + if (report && latestVersion != BuildVersions.KORGE) { + //val lastReportTimeFile = File(korgeCacheDir, "last-report") + //if (!lastReportTimeFile.isFile && System.currentTimeMillis() - lastReportTimeFile.lastModified() >= 24 * 3600 * 1000L) { + // lastReportTimeFile.writeText(System.currentTimeMillis().toString()) + logger.warn(AnsiEscape { + listOf( + "You are using KorGE '${BuildVersions.KORGE}', but there is a new version available '$latestVersion' : $motd".yellow.bgGreen, + "- You can change your KorGE version typically in the file `gradle/libs.versions.toml` or in your `build.gradle.kts`".yellow, + "- You can disable this notice by changing `korge { checkVersion(report = false) }` in your `build.gradle.kts`".yellow, + ).joinToString("\n") + }) + //} + } + } catch (e: Throwable) { + logger.info(e.stackTraceToString()) + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgePluginExtensions.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgePluginExtensions.kt new file mode 100644 index 0000000000..8af21be286 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgePluginExtensions.kt @@ -0,0 +1,30 @@ +package korlibs.korge.gradle + +import org.gradle.api.Project + +class KorgePluginExtensions(val project: Project) { + /* + val pluginExts: List by lazy { + val exts = ServiceLoader.load(KorgePluginExtension::class.java, classLoader).toList() + val ctx = KorgePluginExtension.InitContext() + for (ext in exts) { + for (param in ext.params) { + @Suppress("UNCHECKED_CAST") + (param as KMutableProperty1).set(ext, globalParams[param.name]!!) + } + ext.init(ctx) + } + exts + } + */ + + fun getAndroidDependencies(): List { + // pluginExts.flatMap { it.getAndroidDependencies() } + //TODO() + return listOf() + } + + fun getAndroidManifestApplication(): List = listOf() + fun getAndroidInit(): List = listOf() + +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeProcessResources.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeProcessResources.kt new file mode 100644 index 0000000000..491f3e7bfc --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeProcessResources.kt @@ -0,0 +1,194 @@ +package korlibs.korge.gradle + +import korlibs.korge.gradle.processor.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.jvm.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.file.* +import org.gradle.api.tasks.* +import org.gradle.language.jvm.tasks.* +import org.jetbrains.kotlin.gradle.dsl.* +import org.jetbrains.kotlin.gradle.plugin.* +import java.io.* +import javax.inject.* + +fun Project.getCompilationKorgeProcessedResourcesFolder(compilation: KotlinCompilation<*>): File = + getCompilationKorgeProcessedResourcesFolder(compilation.target.name, compilation.name) + +fun Project.getCompilationKorgeProcessedResourcesFolder( + targetName: String, + compilationName: String +): File = File(project.buildDir, "korgeProcessedResources/${targetName}/${compilationName}") + +fun getKorgeProcessResourcesTaskName(targetName: String, compilationName: String): String = + "korgeProcessedResources${targetName.capitalize()}${compilationName.capitalize()}" + +fun getProcessResourcesTaskName(targetName: String, compilationName: String): String = + "${targetName.decapitalize()}${if (compilationName == "main") "" else compilationName.capitalize()}ProcessResources" + +fun Project.generateKorgeProcessedFromTask(task: ProcessResources) { + val targetNameRaw = task.name.removeSuffix("ProcessResources") + val isTest = targetNameRaw.endsWith("Test") + val targetName = targetNameRaw.removeSuffix("Test") + val target = kotlin.targets.findByName(targetName) ?: return + val isJvm = targetName == "jvm" + val compilationName = if (isTest) "test" else "main" + val korgeGeneratedTaskName = getKorgeProcessResourcesTaskName(target.name, compilationName) + val korgeGeneratedTask = tasks.createThis(korgeGeneratedTaskName) + val korgeGeneratedFolder = getCompilationKorgeProcessedResourcesFolder(targetName, compilationName) + val compilation = target.compilations.findByName(compilationName) + val folders: MutableList = when { + compilation != null -> compilation.allKotlinSourceSets.map { it.resources.sourceDirectories }.toMutableList() + else -> arrayListOf(project.files( + file("resources"), + file("src/commonMain/resources"), + file("src/${targetNameRaw}${compilationName.capitalize()}/resources") + )) + } + + //println("PROJECT: $project : ${this.project.allDependantProjects()}") + + for (subproject in this.project.allDependantProjects()) { + val files = files( + file("resources"), + subproject.file("src/commonMain/resources"), + subproject.file("src/${targetNameRaw}${compilationName.capitalize()}/resources") + ) + //println("ADD : $subproject : ${subproject.file("src/commonMain/resources")}") + folders.add(files) + task?.from(files) + } + + //println("" + project + " :: " + this.project.allDependantProjects()) + //println("project.configurations=${project.configurations["compile"].toList()}") + //println("$project -> dependantProjects=$dependantProjects") + + korgeGeneratedTask.korgeGeneratedFolder = korgeGeneratedFolder + korgeGeneratedTask.inputFolders = folders + korgeGeneratedTask.resourceProcessors = korge.resourceProcessors + + task.from(korgeGeneratedFolder) + task.dependsOn(korgeGeneratedTask) + korgeGeneratedTask.addToCopySpec(task) +} + +fun Project.addGenResourcesTasks() { + if (project.extensions.findByType(KotlinMultiplatformExtension::class.java) == null) return + + val copyTasks = tasks.withType(Copy::class.java) + copyTasks.configureEach { + //it.duplicatesStrategy = org.gradle.api.file.DuplicatesStrategy.WARN + it.duplicatesStrategy = org.gradle.api.file.DuplicatesStrategy.EXCLUDE + //println("Task $this") + } + + val korgeClassPath = project.getKorgeClassPath() + + tasks.createThis("listKorgeTargets") { + group = GROUP_KORGE_LIST + doLast { + println("gkotlin.targets: ${gkotlin.targets.names}") + } + } + + tasks.createThis("listKorgePlugins") { + group = GROUP_KORGE_LIST + doLast { + //URLClassLoader(prepareResourceProcessingClasses.outputs.files.toList().map { it.toURL() }.toTypedArray(), ClassLoader.getSystemClassLoader()).use { classLoader -> + println("KorgePlugins:") + for (item in (korge.resourceProcessors + KorgeResourceProcessor.getAll()).distinct()) { + println("- $item") + } + } + } + + afterEvaluate { + //for (target in kotlin.targets) { + // for (compilation in target.compilations) { + // val taskName = getKorgeProcessResourcesTaskName(target.name, compilation.name) + // tasks.createThis(taskName) // dummy for now + // } + //} + + + for (task in tasks.withType(ProcessResources::class.java).toList()) { + //println("TASK: $task : ${task::class}") + generateKorgeProcessedFromTask(task) + } + } +} + +open class KorgeGenerateResourcesTask @Inject constructor( + //private val fs: FileSystemOperations, +) : DefaultTask() { + @get:OutputDirectory + lateinit var korgeGeneratedFolder: File + + @get:InputFiles + lateinit var inputFolders: List + + //@get:Input + @Internal + lateinit var resourceProcessors: List + + @get:OutputDirectories + var skippedFiles: Set = setOf() + + fun addToCopySpec(copy: CopySpec, addFrom: Boolean = true) { + addToCopySpec(this.korgeGeneratedFolder, this.skippedFiles, copy, addFrom) + } + + companion object { + fun addToCopySpec(korgeGeneratedFolder: File, skippedFiles: Set, copy: CopySpec, addFrom: Boolean = true) { + //println("addToCopySpec.task=$task") + //println("addToCopySpec.copy=$copy") + //println("addToCopySpec.addFrom=$addFrom") + if (addFrom) copy.from(korgeGeneratedFolder) + + copy.exclude { + val relativeFile = File(it.relativePath.toString()) + if (it.relativePath.startsWith('.')) return@exclude true + for (skippedFile in skippedFiles) { + //println("addExcludeToCopyTask: relativeFile=$relativeFile, skippedFile=$skippedFile") + if (relativeFile.startsWith(skippedFile)) { + //println("!! EXCLUDED") + return@exclude true + } + } + false + } + } + } + + @TaskAction + fun run() { + val resourcesFolders = inputFolders.flatMap { it.toList() } + //println("resourcesFolders:\n${resourcesFolders.joinToString("\n")}") + val resourcesSubfolders = resourcesFolders.flatMap { base -> base.walk().filter { it.isDirectory }.map { it.relativeTo(base) } }.distinct() + + for (folder in resourcesSubfolders) { + val korgeGeneratedSubFolder = korgeGeneratedFolder.resolve(folder) + korgeGeneratedSubFolder.mkdirs() + processFolder(korgeGeneratedSubFolder, resourcesFolders.mapNotNull { it.resolve(folder).takeIf { it.isDirectory } }) + } + } + + fun processFolder(generatedFolder: File, resourceFolders: List) { + val context = KorgeResourceProcessorContext(logger, generatedFolder, resourceFolders) + try { + for (processor in (resourceProcessors + KorgeResourceProcessor.getAll()).distinct()) { + try { + processor.processFolder(context) + } catch (e: Throwable) { + e.printStackTrace() + } + } + } catch (e: Throwable) { + e.printStackTrace() + } + skippedFiles += context.skippedFiles + //println("processFolder.skippedFiles=$skippedFiles") + } +} + diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeVersionsTask.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeVersionsTask.kt new file mode 100644 index 0000000000..670540e0fe --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/KorgeVersionsTask.kt @@ -0,0 +1,33 @@ +package korlibs.korge.gradle + +import korlibs.korge.gradle.util.* +import groovy.lang.* +import org.gradle.api.* +import org.gradle.util.* +import org.jetbrains.kotlin.gradle.plugin.* + +object KorgeVersionsTask { + fun registerShowKorgeVersions(project: Project) { + project.tasks.createThis("showKorgeVersions") { + doLast { + println("Build-time:") + for ((key, value) in mapOf( + "os.name" to System.getProperty("os.name"), + "os.version" to System.getProperty("os.version"), + "java.vendor" to System.getProperty("java.vendor"), + "java.version" to System.getProperty("java.version"), + "gradle.version" to GradleVersion.current(), + "groovy.version" to GroovySystem.getVersion(), + "kotlin.runtime.version" to KotlinVersion.CURRENT, + "kotlin.gradle.plugin.version" to getKotlinPluginVersion(logger), + )) { + println(" - $key: $value") + } + println("Korge Gradle plugin:") + for ((key, value) in BuildVersions.ALL) { + println(" - $key: $value") + } + } + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/ProjectDependencyTree.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/ProjectDependencyTree.kt new file mode 100644 index 0000000000..f8e127d5a2 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/ProjectDependencyTree.kt @@ -0,0 +1,37 @@ +package korlibs.korge.gradle + +import korlibs.* +import org.gradle.api.* +import org.gradle.api.artifacts.* + +fun Project.directDependantProjects(): Set { + val key = "directDependantProjects" + if (!project.selfExtra.has(key)) { + //if (true) { + project.selfExtra.set(key, (project.configurations + .flatMap { it.dependencies.withType(ProjectDependency::class.java).toList() } + .map { it.dependencyProject } + .toSet() - project)) + } + return project.selfExtra.get(key) as Set +} + +fun Project.allDependantProjects(): Set { + val key = "allDependantProjects" + if (!project.selfExtra.has(key)) { + //if (true) { + val toExplore = arrayListOf(this) + val out = LinkedHashSet() + val explored = LinkedHashSet() + while (toExplore.isNotEmpty()) { + val item = toExplore.removeAt(toExplore.size - 1) + if (item in explored) continue + val directDependencies = item.directDependantProjects() + explored += item + out += directDependencies + toExplore += directDependencies + } + project.selfExtra.set(key, out - this) + } + return project.selfExtra.get(key) as Set +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/Repos.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/Repos.kt new file mode 100644 index 0000000000..6e83d60730 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/Repos.kt @@ -0,0 +1,29 @@ +package korlibs.korge.gradle + +import org.gradle.api.Project +import org.gradle.api.artifacts.repositories.* +import java.net.URI + +fun Project.configureRepositories() { + fun ArtifactRepository.config() { + content { it.excludeGroup("Kotlin/Native") } + } + + repositories.apply { + mavenLocal().config() + mavenCentral().config() + google().config() + maven { it.url = uri("https://plugins.gradle.org/m2/") }.config() + if (kotlinVersionIsDev) { + maven { it.url = uri("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/bootstrap") }.config() + maven { it.url = uri("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/temporary") }.config() + maven { it.url = uri("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/dev") }.config() + maven { it.url = uri("https://maven.pkg.jetbrains.space/public/p/kotlinx-coroutines/maven") }.config() + maven { it.url = uri("https://oss.sonatype.org/content/repositories/snapshots/") }.config() + maven { it.url = uri("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/temporary") } + maven { it.url = uri("https://maven.pkg.jetbrains.space/public/p/kotlinx-coroutines/maven") } + maven { it.url = uri("https://maven.pkg.jetbrains.space/kotlin/p/wasm/experimental") } + } + //println("kotlinVersion=$kotlinVersion") + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/Versions.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/Versions.kt new file mode 100644 index 0000000000..7cddb3360d --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/Versions.kt @@ -0,0 +1,10 @@ +package korlibs.korge.gradle + +import org.gradle.api.* + +val Project.jnaVersion get() = findProperty("jnaVersion") ?: BuildVersions.JNA +val Project.korgeVersion get() = findProperty("korgeVersion") ?: BuildVersions.KORGE +val Project.kotlinVersion: String get() = findProperty("kotlinVersion")?.toString() ?: BuildVersions.KOTLIN +val Project.kotlinVersionIsDev: Boolean get() = kotlinVersion.contains("-release") || kotlinVersion.contains("-eap") || kotlinVersion.contains("-M") || kotlinVersion.contains("-RC") +val Project.androidBuildGradleVersion get() = findProperty("androidBuildGradleVersion") ?: BuildVersions.ANDROID_BUILD +val Project.coroutinesVersion get() = findProperty("coroutinesVersion") ?: BuildVersions.COROUTINES diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/bundle/KorgeBundle.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/bundle/KorgeBundle.kt new file mode 100644 index 0000000000..b09aa198b5 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/bundle/KorgeBundle.kt @@ -0,0 +1,263 @@ +package korlibs.korge.gradle.bundle + +/* +import korlibs.korge.gradle.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.artifacts.* +import org.gradle.api.file.* +import java.io.* +import java.net.* +import java.security.* + +class KorgeBundles(val project: Project) { + val bundlesDir get() = project.file("bundles").also { it.mkdirs() } + val logger get() = project.logger + val bundles = arrayListOf() + data class BundleInfo( + val path: File, + val bundleName: String, + val repositories: List, + val dependencies: List + ) { + fun dependenciesForSourceSet(sourceSet: String) = dependencies.filter { it.sourceSet == sourceSet } + fun dependenciesForSourceSet(sourceSet: Set) = dependencies.filter { it.sourceSet in sourceSet } + } + data class BundleRepository(val url: String) + data class BundleDependency(val sourceSet: String, val artifactPath: String) + + fun sha256Tree(tree: FileTree): String { + val files = LinkedHashMap() + tree.visit { + it.apply { + if (!isDirectory) { + val mpath = path.trim('/') + val rpath = "/$mpath" + when { + rpath.contains("/.git") -> Unit + rpath.endsWith("/.DS_Store") -> Unit + rpath.endsWith("/thumbs.db") -> Unit + else -> files[mpath] = file + } + } + } + } + val digest = MessageDigest.getInstance("SHA-256") + for (fileKey in files.keys.toList().sorted()) { + val file = files[fileKey]!! + val hashString = "$fileKey[${file.length()}]" + digest.update(hashString.toByteArray(Charsets.UTF_8)) + digest.update(file.readBytes()) + if (logger.isInfoEnabled) { + logger.info("SHA256: $hashString: ${(digest.clone() as MessageDigest).digest().hex}") + } + } + return digest.digest().hex + } + + val buildDirBundleFolder = "korge-bundles" + + @JvmOverloads + fun bundle(zipFile: File, baseName: String? = null, checkSha256: String? = null) { + val bundleName = baseName ?: zipFile.name.removeSuffix(".korgebundle") + val outputDir = project.file("${project.buildDir}/$buildDirBundleFolder/$bundleName") + val tree = if (zipFile.isDirectory) project.fileTree(zipFile) else project.zipTree(zipFile) + val computedSha256 = sha256Tree(tree) + + if (checkSha256.isNullOrEmpty()) { + println("Bundle $bundleName hash missing; calculated SHA256: $computedSha256") + } + + if (!outputDir.exists()) { + logger.warn("KorGE.bundle: Extracting $zipFile...") + + when { + checkSha256 == null -> logger.warn(" - Security WARNING! Not checking SHA256 for bundle $bundleName. That should be SHA256 = $computedSha256") + checkSha256 != computedSha256 -> error("Bundle '$bundleName' expects SHA256 = $checkSha256 , but found SHA256 = $computedSha256") + else -> logger.info("Matching bundle SHA256 = $computedSha256") + } + //println("SHA256: ${sha256Tree(tree)}") + + project.sync { + it.from(tree) + it.into(outputDir) + } + } else { + logger.info("KorGE.bundle: Already unzipped $zipFile") + } + + val repositories = arrayListOf() + val dependencies = arrayListOf() + val dependenciesTxtFile = File(outputDir, "dependencies.txt") + if (dependenciesTxtFile.exists()) { + for (rline in dependenciesTxtFile.readLines()) { + val line = rline.trim() + if (line.startsWith("#")) continue + val (key, value) = line.split(":", limit = 2).map { it.trim() }.takeIf { it.size >= 2 } ?: continue + if (key == "repository") { + repositories.add(BundleRepository(value)) + } else { + dependencies.add(BundleDependency(key, value)) + } + } + } + + bundles += BundleInfo( + path = outputDir, + bundleName = bundleName, + repositories = repositories, + dependencies = dependencies, + ) + + project.afterEvaluate { + for (repo in repositories) { + logger.info("KorGE.bundle.repository: $repo") + project.repositories.maven { + it.url = project.uri(repo.url) + } + } + for (dep in dependencies) { + //val available = dep.sourceSet in project.configurations + val available = try { + project.configurations.getAt(dep.sourceSet) != null + } catch (e: UnknownConfigurationException) { + false + } + logger.info("KorGE.bundle.dependency: $dep -- available=$available") + if (available) { + project.dependencies.add(dep.sourceSet, dep.artifactPath) + } + } + logger.info("KorGE.bundle: $outputDir") + for (target in project.gkotlin.targets) { + logger.info(" target: $target") + target.compilations.all { compilation -> + logger.info(" compilation: $compilation") + + val sourceSets = compilation.kotlinSourceSets.toMutableSet() + + for (sourceSet in sourceSets) { + logger.info(" sourceSet: $sourceSet") + + fun addSource(ss: SourceDirectorySet, sourceSetName: String, folder: String) { + val folder = File(project.buildDir, "$buildDirBundleFolder/${bundleName}/src/${sourceSetName}/$folder") + if (folder.exists()) { + logger.info(" ${ss.name}Src: $folder") + ss.srcDirs(folder) + } + } + + fun addSources(sourceSetName: String) { + addSource(sourceSet.kotlin, sourceSetName, "kotlin") + addSource(sourceSet.resources, sourceSetName, "resources") + } + + fun addSourcesAddSuffix(sourceSetName: String) { + when { + sourceSet.name.endsWith("Test") -> addSources("${sourceSetName}Test") + sourceSet.name.endsWith("Main") -> addSources("${sourceSetName}Main") + } + } + + addSources(sourceSet.name) + if (target.isNative) addSourcesAddSuffix("nativeCommon") + if (target.isNativeDesktop) addSourcesAddSuffix("nativeDesktop") + if (target.isNativePosix) addSourcesAddSuffix("nativePosix") + if (target.isNativePosix && !target.isApple) addSourcesAddSuffix("nativePosixNonApple") + if (target.isApple) addSourcesAddSuffix("nativePosixApple") + if (target.isTvos) addSourcesAddSuffix("iosTvosCommon") + if (target.isMacos || target.isIosTvos) addSourcesAddSuffix("macosIosTvosCommon") + if (target.isIos) addSourcesAddSuffix("iosCommon") + } + } + } + } + //project.gkotlin.sourceSets.configureEach { + // logger.info("KorGE.sourceSet: ${it.name}") + //} + //println(project.gkotlin.metadata().compilations["main"].kotlinSourceSets) + } + + @JvmOverloads + fun bundle(url: java.net.URL, baseName: String? = null, checkSha256: String? = null) { + val outFile = bundlesDir["${baseName ?: File(url.path).nameWithoutExtension}.korgebundle"] + if (!outFile.exists()) { + logger.warn("KorGE.bundle: Downloading $url...") + outFile.writeBytes(url.readBytes()) + } else { + logger.info("KorGE.bundle: Already downloaded $url") + } + bundle(outFile, baseName, checkSha256) + } + + @JvmOverloads + fun bundleGit(repo: String, folder: String = "", ref: String = "master", bundleName: String? = null, checkSha256: String? = null) { + val repoURL = URL(repo) + val packPath = "${repoURL.host}/${repoURL.path}/$ref" + .replace("\\", "/") + .trim('/') + .replace(Regex("/+"), "/") + .replace(".git", "") + .replace("/..", "") + + val packDir = File(bundlesDir, packPath) + val packEnsure = File(bundlesDir, "$packPath.refname") + val existsDotGitFolder = File(packDir, ".git").exists() + val matchingReg = packEnsure.takeIf { it.exists() }?.readText() == ref + + if (!matchingReg && !existsDotGitFolder) { + packDir.mkdirs() + logger.warn("KorGE.bundle: Git cloning $repo @ $ref...") + project.execThis { + workingDir(packDir) + commandLine("git", "-c", "core.autocrlf=false", "clone", repo, ".") + }.assertNormalExitValue() + } else { + logger.info("KorGE.bundle: Already cloned $repo") + } + + if (!matchingReg) { + project.execThis { + workingDir(packDir) + commandLine("git", "-c", "core.autocrlf=false", "reset", "--hard", ref) + }.assertNormalExitValue() + project.delete { + it.delete(File(packDir, ".git")) + } + packEnsure.writeText(ref) + } else { + logger.info("KorGE.bundle: Already at reference $ref @ $repo") + } + + + bundle(File(packDir, folder), bundleName, checkSha256) + } + + @JvmOverloads + fun bundle(fullUri: String, baseName: String? = null) { + val (uri, ssha256) = (fullUri.split("##", limit = 2) + listOf("")) + val sha256 = ssha256.takeIf { it.isNotEmpty() } + when { + uri.contains(".git") -> { + val parts = uri.split("::", limit = 3) + bundleGit(parts[0], parts.getOrElse(1) { "" }, parts.getOrElse(2) { "master" }, parts.getOrNull(3), checkSha256 = sha256) + } + uri.startsWith("http://") || uri.startsWith("https://") -> { + bundle(URL(uri), baseName, checkSha256 = sha256) + } + else -> { + bundle(project.file(uri), baseName, checkSha256 = sha256) + } + } + } + + fun getPaths(name: String, resources: Boolean, test: Boolean): Set { + val lfolder = if (resources) "resources" else "kotlin" + val lmain = if (test) "Test" else "Main" + return bundles.flatMap { bundle -> + listOf(File(bundle.path, "src/${name}$lmain/$lfolder")) + }.filter { it.isDirectory && it.exists() }.toSet() + } +} +*/ diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/generate/TemplateGenerator.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/generate/TemplateGenerator.kt new file mode 100644 index 0000000000..141ce37bfe --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/generate/TemplateGenerator.kt @@ -0,0 +1,142 @@ +package korlibs.korge.gradle.generate + +import korlibs.text +import java.io.File + +object TemplateGenerator { + @JvmStatic + @JvmOverloads + fun synchronizeNew(file: File, includeByte: Boolean = true, includeFloat: Boolean = true, includeGeneric: Boolean = true, includeShort: Boolean = false, includeLong: Boolean = false, includeChar: Boolean = false, includeBoolean: Boolean = false, includeBit: Boolean = false) { + val STRING_START = "// GENERIC TEMPLATE //////////////////////////////////////////\n" + val STRING_END = "// END OF GENERIC TEMPLATE ///////////////////////////////////\n" + val NOTICE = "// AUTOGENERATED: DO NOT MODIFY MANUALLY STARTING FROM HERE!\n" + + val text = file.readText() + val header = text.substringBefore(STRING_START) + val template = text.substringAfter(STRING_START).substringBefore(STRING_END) + val types = ArrayList().apply { + add("Int") + add("Double") + if (includeFloat) add("Float") + if (includeByte) add("Byte") + if (includeChar) add("Char") + if (includeShort) add("Short") + if (includeLong) add("Long") + if (includeBoolean) add("Boolean") + // if (includeBit) add("Bit") + } + val rest = types.joinToString("\n\n") { + "// $it\n\n" + template.replaceTemplate(it) + } + + val generated = "$header$STRING_START$template$STRING_END\n$NOTICE\n$rest".trimEnd() + "\n" + if (file.text != generated) { + file.text = generated + } + } + + @JvmOverloads + @JvmStatic + fun synchronize(src: File, dst: File, includeFloat: Boolean = true, includeGeneric: Boolean = false, includeByte: Boolean = false) { + val content = src.readText() + val parts = content.split("// GENERIC\n") + val head = parts[0].trim() + val generic = parts.getOrElse(1) { "" } + + val types = listOf("Int", "Double") + (if (includeFloat) listOf("Float") else listOf()) + (if (includeByte) listOf("Byte") else listOf()) + + /* + dst.writeText( + ("$head\n\n// AUTOGENERATED: DO NOT MODIFY MANUALLY!\n\n" + + (if (includeGeneric) generic + "\n\n" else "") + + types.joinToString("\n\n") { "// $it\n" + generic.replaceTemplate(it) }) + .restoreCollectionKinds() + ) + */ + } + + fun String.restoreCollectionKinds(): String = this + .restoreCollectionKinds("Byte") + .restoreCollectionKinds("Int") + .restoreCollectionKinds("Float") + .restoreCollectionKinds("Double") + + fun String.restoreCollectionKinds(kind: String): String { + return this.replace("${kind}List", "List<$kind>") + + .replace("${kind}MutableIterator", "MutableIterator<$kind>") + .replace("${kind}MutableCollection", "MutableCollection<$kind>") + + .replace("${kind}Iterable", "Iterable<$kind>") + .replace("${kind}Iterator", "Iterator<$kind>") + .replace("${kind}Collection", "Collection<$kind>") + + .replace("${kind}Comparable", "Comparable<$kind>") + .replace("${kind}Comparator", "Comparator<$kind>") + } + + fun String.replaceTemplate(kind: String): String { + val lkind = kind.toLowerCase() + return this + .replace("FastArrayList", "ArrayList") + .replace("arrayOfNulls", "${kind}Array") + .replace("arrayOfNulls", "${kind}Array") + //.replace("arrayOfNulls", "${kind}Array") + .replace("", "") + .replace("", "") + .replace("<*/*_TGen_*/>", "") + .replace("null/*TGen*/", when (kind) { + "Boolean" -> "false" + "Byte" -> "0.toByte()" + "Short" -> "0.toShort()" + "Char" -> "'\\u0000'" + "Int" -> "0" + "Long" -> "0L" + "Float" -> "0f" + "Double" -> "0.0" + else -> "0" + }) + .replace(">", "") + .replace(">", "") + .replace("Iterable", "Iterable<$kind>") + .replace("Collection", "Collection<$kind>") + .replace("fun ", "fun") + .replace("arrayListOf", "${lkind}ArrayListOf") + //.replace("List", "${kind}ArrayList") + .replace("Array", "${kind}Array") + .replace("Array", "${kind}Array") + .replace("Array", "${kind}Array") + .replace("Array", "${kind}Array") + .replace(Regex("""(\w+)""")) { + val base = it.groupValues[1] + val name = base.replace("TGen", "") + when (base) { + "List" -> "List<$kind>" + "Iterator" -> "Iterator<$kind>" + "MutableIterator" -> "MutableIterator<$kind>" + "ListIterator" -> "ListIterator<$kind>" + else -> "$kind$name" + } + } + .replace(Regex("""(\w+)<\*/\*TGen\*/>""")) { + val base = it.groupValues[1] + val name = base.replace("TGen", "") + when (base) { + "List" -> "List<$kind>" + "Iterator" -> "Iterator<$kind>" + "MutableIterator" -> "MutableIterator<$kind>" + "ListIterator" -> "ListIterator<$kind>" + else -> "$kind$name" + } + } + .replace(": TGen", ": $kind") + .replace("-> TGen", "-> $kind") + .replace("as TGen", "as $kind") + .replace("(TGen)", "($kind)") + .replace("TGen, ", "$kind, ") + .replace("TGen", kind) + .replace("tgen", lkind) + + } + +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/module/CopyResources.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/module/CopyResources.kt new file mode 100644 index 0000000000..f4dd3ef2ee --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/module/CopyResources.kt @@ -0,0 +1,64 @@ +package korlibs.korge.gradle.module + +import korlibs.korge.gradle.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.desktop.* +import korlibs.korge.gradle.targets.native.* +import korlibs.korge.gradle.targets.windows.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.file.* +import org.gradle.api.tasks.* +import org.jetbrains.kotlin.gradle.plugin.mpp.* + +/* +fun Project.createCopyToExecutableTarget(target: String) { + for (build in KNTargetWithBuildType.buildList(project, target)) { + val copyTask = project.tasks.createThis(build.copyTaskName) { + run { + val processResourcesTaskName = getProcessResourcesTaskName(build.target, build.compilation.name) + dependsOn(processResourcesTaskName) + afterEvaluate { + afterEvaluate { + afterEvaluate { + val korgeGenerateResourcesTask = tasks.findByName(processResourcesTaskName) as? Copy? + //korgeGenerateResourcesTask?.korgeGeneratedFolder?.let { from(it) } + from(korgeGenerateResourcesTask?.outputs) + } + } + } + } + run { + val processResourcesTaskName = getKorgeProcessResourcesTaskName(build.target, build.compilation.name) + dependsOn(processResourcesTaskName) + afterEvaluate { + afterEvaluate { + afterEvaluate { + val korgeGenerateResourcesTask = + tasks.findByName(processResourcesTaskName) as? KorgeGenerateResourcesTask? + //korgeGenerateResourcesTask?.korgeGeneratedFolder?.let { from(it) } + korgeGenerateResourcesTask?.addToCopySpec(this@createThis, addFrom = false) + } + } + } + } + dependsOn(build.compilation.compileKotlinTask) + duplicatesStrategy = DuplicatesStrategy.INCLUDE + for (sourceSet in project.gkotlin.sourceSets) { + from(sourceSet.resources) + } + //println("executableFile.parentFile: ${executableFile.parentFile}") + into(build.executableFile.parentFile) + } + + afterEvaluate { + try { + val linkTask = build.compilation.getLinkTask(NativeOutputKind.EXECUTABLE, build.buildType, project) + linkTask.dependsOn(copyTask) + } catch (e: Throwable) { + e.printStackTrace() + } + } + } +} +*/ diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/module/Idea.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/module/Idea.kt new file mode 100644 index 0000000000..caacd7cabf --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/module/Idea.kt @@ -0,0 +1,49 @@ +package korlibs.korge.gradle.module + +import korlibs.korge.gradle.util.* +import org.gradle.api.Project +import org.gradle.plugins.ide.idea.model.* + +fun Project.configureIdea() { + project.plugins.applyOnce("idea") + //val plugin = this.plugins.apply(IdeaPlugin::class.java) + //val idea = this.extensions.getByType() + + project.extensions.getByName("idea").apply { + module { + val module = it + module.excludeDirs = module.excludeDirs.also { + it.addAll( + listOf( + ".gradle", ".idea", "gradle/wrapper", ".idea", "build", "@old", "_template", "docs", + "kotlin-js-store", "archive", + "e2e-test/.gradle", "e2e-test/.idea", "e2e-test/build", + "e2e-test-multi/.gradle", "e2e-test-multi/.idea", "e2e-test-multi/build", + ) + .map { file(it) } + ) + } + } + } +} + +/* +fun Project.initIdeaExcludes() { + allprojectsThis { + if (project.hasBuildGradle()) { + val plugin = this.plugins.apply(IdeaPlugin::class.java) + val idea = this.extensions.getByType() + + idea.apply { + module { + it.excludeDirs = it.excludeDirs + listOf( + file(".gradle"), file("src2"), file("original"), file("original-tests"), file("old-rendering"), + file("gradle/wrapper"), file(".idea"), file("build"), file("@old"), file("_template"), + file("e2e-sample"), file("e2e-test"), file("experiments"), + ) + } + } + } + } +} +*/ diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/processor/CatalogGenerator.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/processor/CatalogGenerator.kt new file mode 100644 index 0000000000..044ff324da --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/processor/CatalogGenerator.kt @@ -0,0 +1,21 @@ +package korlibs.korge.gradle.processor + +import korlibs.korge.gradle.util.* + +open class CatalogGenerator : KorgeResourceProcessor { + override fun processFolder(context: KorgeResourceProcessorContext) { + val map = LinkedHashMap() + for (folder in (context.resourceFolders + context.generatedFolder)) { + for (file in (folder.listFiles()?.toList() ?: emptyList())) { + if (file.name == "\$catalog.json") continue + if (file.name.startsWith(".")) continue + val fileName = if (file.isDirectory) "${file.name}/" else file.name + map[fileName] = listOf(file.length(), file.lastModified()) + } + } + //println("-------- $folders") + //println("++++++++ $files") + context.generatedFolder["\$catalog.json"].writeText(Json.stringify(map)) + //println("generatedFolder: $generatedFolder") + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/processor/KorgeResourceProcessor.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/processor/KorgeResourceProcessor.kt new file mode 100644 index 0000000000..56bd1f8406 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/processor/KorgeResourceProcessor.kt @@ -0,0 +1,41 @@ +package korlibs.korge.gradle.processor + +import korlibs.korge.gradle.util.* +import org.gradle.api.file.RelativePath +import java.io.* +import java.util.* +import kotlin.collections.LinkedHashSet + +data class KorgeResourceProcessorContext( + val logger: org.slf4j.Logger, + val generatedFolder: File, + val resourceFolders: List, +) { + val skippedFiles = LinkedHashSet() + + /** + * Prevents copying that [files] or folder to the final executable + */ + fun skipFiles(vararg files: File) { + for (file in files) { + for (folder in resourceFolders) { + if (file.isDescendantOf(folder)) { + skippedFiles += file.relativeTo(folder).path + (if (file.isDirectory) "/" else "") + break + } + } + } + } +} + +fun interface KorgeResourceProcessor { + fun processFolder(context: KorgeResourceProcessorContext) + + //override fun toString(): String = "${this::class.qualifiedName}" + + companion object { + fun getAll(): List { + return (ServiceLoader.load(KorgeResourceProcessor::class.java).toList() + CatalogGenerator()) + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/processor/KorgeTexturePacker.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/processor/KorgeTexturePacker.kt new file mode 100644 index 0000000000..331d98f018 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/processor/KorgeTexturePacker.kt @@ -0,0 +1,53 @@ +package korlibs.korge.gradle.processor + +import korlibs.korge.gradle.texpacker.* +import korlibs.korge.gradle.util.* +import java.io.* +import kotlin.system.* + +open class KorgeTexturePacker : KorgeResourceProcessor { + override fun processFolder(context: KorgeResourceProcessorContext) { + for (folder in context.resourceFolders) { + val files = folder.listFiles()?.toList() ?: emptyList() + for (file in files) { + if (file.name.endsWith(".atlas")) { + val atlasJsonFile = File(context.generatedFolder, file.nameWithoutExtension + ".atlas.json") + when { + file.isDirectory -> { + context.skipFiles(file) + generate(context.logger, atlasJsonFile, arrayOf(file)) + } + file.isFile -> { + val sources = file.readLines().filter { it.isNotBlank() }.map { File(folder, it) }.toTypedArray() + context.skipFiles(file, *sources) + generate(context.logger, atlasJsonFile, sources) + } + } + } + } + } + } + + fun generate(logger: org.slf4j.Logger, outputFile: File, imageFolders: Array) { + val involvedFiles = NewTexturePacker.getAllFiles(*imageFolders) + //val maxLastModifiedTime = involvedFiles.maxOfOrNull { it.file.lastModified() } ?: System.currentTimeMillis() + val involvedString = involvedFiles.map { it.relative.name + ":" + it.file.length() + ":" + it.file.lastModified() }.sorted().joinToString("\n") + val involvedFile = File(outputFile.parentFile, "." + outputFile.name + ".info") + + //if (!outputFile.exists() || involvedFile.takeIfExists()?.readText() != involvedString) { + if (involvedFile.takeIfExists()?.readText() != involvedString) { + val time = measureTimeMillis { + val results = NewTexturePacker.packImages(*imageFolders, enableRotation = true, enableTrimming = true) + for (result in results) { + val imageOut = result.write(outputFile) + } + involvedFile.writeText(involvedString) + } + //outputFile.setLastModified(maxLastModifiedTime) + //imageOut.setLastModified(maxLastModifiedTime) + logger.info("KorgeTexturePacker.GENERATED in ${time}ms: $involvedFile") + } else { + logger.info("KorgeTexturePacker.CACHED: $involvedFile") + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/CrossExecType.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/CrossExecType.kt new file mode 100644 index 0000000000..027849f9c1 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/CrossExecType.kt @@ -0,0 +1,95 @@ +package korlibs.korge.gradle.targets + +import korlibs.korge.gradle.util.* +import org.gradle.configurationcache.extensions.* + +object WineHQ { + val EXEC = "wine64" +} + +object Box64 { + val VERSION = "v0.2.2" + + fun exec(vararg params: String): SystemExecResult { + return executeSystemCommand(ArrayList().apply { + when { + isWindows -> add("wsl") + isMacos -> add("lima") + } + addAll(params) + }.toTypedArray()) + } + + fun whichBox64(): String? = exec("which", "box64").let { it.takeIf { it.success }?.stdout } + + fun ensureBox64(): String { + val box64 = whichBox64() + if (box64 == null) { + exec("mkdir", "-p", "/tmp/box64").stdout.trim() + val box64Path = exec("realpath", "/tmp/box64").stdout.trim() + + exec("sudo", "apt", "update") + exec("sudo", "apt", "-y", "install", "git", "build-essential", "cmake") + exec("git", "clone", "-b", VERSION, "https://github.com/ptitSeb/box64.git", box64Path) + exec("cmake", "-S", box64Path, "-B", box64Path) + exec("make", "-C", box64Path) + exec("sudo", "make", "-C", box64Path, "install") + } + return box64 ?: whichBox64() ?: error("Couldn't install box64") + } +} + +class CommandLineCrossResult(val hasBox64: Boolean) { + fun ensure() { + if (hasBox64) { + Box64.ensureBox64() + } + } +} + +enum class CrossExecType(val cname: String, val interp: String, val arch: String = "X64") { + WINDOWS("mingw", "wine"), + LINUX("linux", "lima"), + LINUX_ARM("linux", "lima", arch = "Arm64"), + ; + + val valid: Boolean get() = when (this) { + WINDOWS -> !isWindows + LINUX -> !isLinux + LINUX_ARM -> !isLinux && isArm + } + + val archNoX64: String = if (arch == "X64") "" else arch + val interpCapital = interp.capitalized() + archNoX64 + val nameWithArch = "${cname}$arch" + val nameWithArchCapital = nameWithArch.capitalized() + + fun commands(vararg args: String): Pair> { + var hasBox64 = false + val array = ArrayList().apply { + when (this@CrossExecType) { + WINDOWS -> { + if (isArm && !isMacos) { + add("box64") + hasBox64 = true + } // wine on macOS can run x64 apps via rosetta, but linux needs box64 emulator + add(WineHQ.EXEC) + } + LINUX, LINUX_ARM -> { + // @TODO: WSL + if (isWindows) add("wsl") else add("lima") + if (isArm && this@CrossExecType != LINUX_ARM) { + add("box64") + hasBox64 = true + } + } + } + addAll(args) + }.toTypedArray() + return CommandLineCrossResult(hasBox64) to array + } + + companion object { + val VALID_LIST: List = values().filter { it.valid } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/Icons.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/Icons.kt new file mode 100644 index 0000000000..316dfbdb0a --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/Icons.kt @@ -0,0 +1,48 @@ +package korlibs.korge.gradle.targets + +import korlibs.korge.gradle.* +import korlibs.korge.gradle.util.encodePNG +import korlibs.korge.gradle.util.getScaledInstance +import korlibs.korge.gradle.util.toBufferedImage +import org.gradle.api.* +import java.io.* +import javax.imageio.ImageIO + +val ICON_SIZES = listOf(20, 29, 40, 44, 48, 50, 55, 57, 58, 60, 72, 76, 80, 87, 88, 100, 114, 120, 144, 152, 167, 172, 180, 196, 1024) + +fun tryGetResourceBytes(path: String): ByteArray? = + KorgeExtension::class.java.getResource("/" + path.trim('/'))?.readBytes() + +fun getResourceBytes(path: String): ByteArray = tryGetResourceBytes(path) ?: error("Can't find resource '$path'") +fun getResourceString(path: String): String = getResourceBytes(path).toString(Charsets.UTF_8) + +val KorgeExtension.iconProvider: KorgeIconProvider get() = KorgeIconProvider(this) + +fun KorgeExtension.getIconBytes(): ByteArray = iconProvider.getIconBytes() +fun KorgeExtension.getBannerBytes(): ByteArray = iconProvider.getBannerBytes() + +fun KorgeExtension.getIconBytes(width: Int, height: Int = width): ByteArray = iconProvider.getIconBytes(width, height) +fun KorgeExtension.getBannerBytes(width: Int, height: Int = width): ByteArray = iconProvider.getBannerBytes(width, height) + +class KorgeIconProvider(val icon: File? = null, val banner: File? = null) { + constructor(korge: KorgeExtension) : this(korge.icon, korge.banner) + constructor(project: Project) : this(project.korge) + + fun iconExists(): Boolean = icon != null && icon.exists() + fun bannerExists(): Boolean = banner != null && banner.exists() + + fun getIconBytes(): ByteArray = when { + iconExists() -> icon!!.readBytes() + else -> getResourceBytes("/icons/korge.png") + } + + fun getBannerBytes(): ByteArray = when { + bannerExists() -> banner!!.readBytes() + iconExists() -> icon!!.readBytes() + else -> getResourceBytes("/banners/korge.png") + } + + fun getIconBytes(width: Int, height: Int = width): ByteArray = ImageIO.read(getIconBytes().inputStream()).getScaledInstance(width, height).toBufferedImage().encodePNG() + fun getBannerBytes(width: Int, height: Int = width): ByteArray = ImageIO.read(getBannerBytes().inputStream()).getScaledInstance(width, height).toBufferedImage().encodePNG() + +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ModuleResources.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ModuleResources.kt new file mode 100644 index 0000000000..49f354b164 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ModuleResources.kt @@ -0,0 +1,14 @@ +package korlibs.korge.gradle.targets + +import org.gradle.api.* +import org.gradle.api.file.* +import java.io.* + +fun CopySpec.registerModulesResources(project: Project) { + project.afterEvaluate { + for (file in (project.rootDir.resolve("modules").listFiles()?.toList() ?: emptyList())) { + from(File(file, "resources")) + from(File(file, "src/commonMain/resources")) + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ProjectType.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ProjectType.kt new file mode 100644 index 0000000000..18dce15821 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ProjectType.kt @@ -0,0 +1,12 @@ +package korlibs.korge.gradle.targets + +enum class ProjectType { + EXECUTABLE, LIBRARY; + + val isExecutable: Boolean get() = this == EXECUTABLE + val isLibrary: Boolean get() = this == LIBRARY + + companion object { + fun fromExecutable(executable: Boolean) = if (executable) EXECUTABLE else LIBRARY + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/SourceSets.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/SourceSets.kt new file mode 100644 index 0000000000..c59c0f8d1a --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/SourceSets.kt @@ -0,0 +1,31 @@ +package korlibs.korge.gradle.targets + +import korlibs.korge.gradle.* +import org.gradle.api.* + +val Project.exKotlinSourceSetContainer: ExKotlinSourceSetContainer get() = extensionGetOrCreate("exKotlinSourceSetContainer") + +open class ExKotlinSourceSetContainer(val project: Project) { + val kotlin = project.kotlin + val sourceSets = kotlin.sourceSets + + val common by lazy { sourceSets.createPairSourceSet("common", project = project) } + val nonJs by lazy { sourceSets.createPairSourceSet("nonJs", common, project = project) } + val concurrent by lazy { sourceSets.createPairSourceSet("concurrent", common, project = project) } + + // JS + val js by lazy { sourceSets.createPairSourceSet("js", common, project = project) } + + // JVM + val jvm by lazy { sourceSets.createPairSourceSet("jvm", concurrent, nonJs, project = project) } + + // Native + val native by lazy { sourceSets.createPairSourceSet("native", concurrent, nonJs, project = project) } + val posix by lazy { sourceSets.createPairSourceSet("posix", native, project = project) } + val darwin by lazy { sourceSets.createPairSourceSet("darwin", posix, project = project) } + val darwinMobile by lazy { sourceSets.createPairSourceSet("darwinMobile", darwin, project = project) } + val iosTvos by lazy { sourceSets.createPairSourceSet("iosTvos", darwinMobile/*, iosTvosMacos*/, project = project) } + val tvos by lazy { sourceSets.createPairSourceSet("tvos", iosTvos, project = project) } + val ios by lazy { sourceSets.createPairSourceSet("ios", iosTvos/*, iosMacos*/, project = project) } +} + diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/Targets.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/Targets.kt new file mode 100644 index 0000000000..9dcd1a9a19 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/Targets.kt @@ -0,0 +1,77 @@ +package korlibs.korge.gradle.targets + +import org.apache.tools.ant.taskdefs.condition.* +import org.gradle.api.* +import org.jetbrains.kotlin.gradle.plugin.* +import java.io.* + +// Only mac has ios/tvos targets but since CI exports multiplatform on linux +val supportKotlinNative: Boolean get() { + // Linux and Windows ARM hosts doesn't have K/N toolchains + if (isArm && (isLinux || isWindows)) return false + // We can also try to disable it manually + if (System.getenv("DISABLE_KOTLIN_NATIVE") == "true") return false + // On Mac, CI or when FORCE_ENABLE_KOTLIN_NATIVE=true, let's enable it + //return isMacos || (System.getenv("CI") == "true") || (System.getenv("FORCE_ENABLE_KOTLIN_NATIVE") == "true") + return true +} + +val isWindows get() = Os.isFamily(Os.FAMILY_WINDOWS) +val isMacos get() = Os.isFamily(Os.FAMILY_MAC) +val isLinux get() = Os.isFamily(Os.FAMILY_UNIX) && !isMacos +val isArm get() = listOf("arm", "arm64", "aarch64").any { Os.isArch(it) } +val inCI: Boolean get() = !System.getenv("CI").isNullOrBlank() || !System.getProperty("CI").isNullOrBlank() + +val KotlinTarget.isIos get() = name.startsWith("ios") +val KotlinTarget.isTvos get() = name.startsWith("tvos") +val KotlinTarget.isLinux get() = name.startsWith("linux") +val KotlinTarget.isMingw get() = name.startsWith("mingw") +val KotlinTarget.isMacos get() = name.startsWith("macos") + +fun NamedDomainObjectContainer.createPairSourceSet( + name: String, + vararg dependencies: PairSourceSet, doTest: Boolean = true, + project: Project? = null, + block: KotlinSourceSet.(test: Boolean) -> Unit = { } +): PairSourceSet { + val main = maybeCreate("${name}Main").apply { block(false) } + val test = if (doTest) maybeCreate("${name}Test").apply { block(true) } else null + + val newVariant = if (project?.projectDir != null) !File(project.projectDir, "src/commonMain").isDirectory else false + + //println("!!!!!!!!!!! newVariant=$newVariant, project?.projectDir=${project?.projectDir} isDirectory=${project?.projectDir?.get("src/commonMain")?.isDirectory}") + + if (newVariant) { + if (name == "common") { + main.kotlin.srcDirs(listOf("src")) + main.resources.srcDirs(listOf("resources")) + } else { + main.kotlin.srcDirs(listOf("src@$name")) + } + if (test != null) { + //test.kotlin.srcDirs(listOf("test/$name")) + if (name == "common") { + test.kotlin.srcDirs(listOf("test")) + test.resources.srcDirs(listOf("testresources")) + } else { + test.kotlin.srcDirs(listOf("test@$name")) + } + } + } + + return PairSourceSet(main, test).also { + for (dependency in dependencies) { + it.dependsOn(dependency) + } + } +} + +data class PairSourceSet(val main: KotlinSourceSet, val test: KotlinSourceSet?) { + fun get(test: Boolean) = if (test) this.test else this.main + fun dependsOn(vararg others: PairSourceSet) { + for (other in others) { + main.dependsOn(other.main) + other.test?.let { test?.dependsOn(it) } + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/TaskGroups.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/TaskGroups.kt new file mode 100644 index 0000000000..89e3a658cf --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/TaskGroups.kt @@ -0,0 +1,7 @@ +package korlibs.korge.gradle.targets + +const val GROUP_KORGE_RUN: String = "@korge run" +const val GROUP_KORGE_ADB: String = "@korge adb" +const val GROUP_KORGE_LIST: String = "@korge list" +const val GROUP_KORGE_PACKAGE: String = "@korge package" +const val GROUP_KORGE_INSTALL: String = "@korge install" diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/all/All.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/all/All.kt new file mode 100644 index 0000000000..2a58940179 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/all/All.kt @@ -0,0 +1,49 @@ +package korlibs.korge.gradle.targets.all + +import korlibs.* +import korlibs.korge.gradle.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.jetbrains.kotlin.gradle.dsl.* +import org.jetbrains.kotlin.gradle.plugin.* +import java.io.* + +val Project.korgeGradlePluginResources: File get() = File(rootProject.projectDir, "buildSrc/src/main/resources") + +fun Project.rootEnableFeaturesOnAllTargets() { + rootProject.subprojectsThis { + enableFeaturesOnAllTargets() + } +} + +object AddFreeCompilerArgs { + @JvmStatic + fun addFreeCompilerArgs(project: Project, target: KotlinTarget) { + target.compilations.configureEach { compilation -> + val options = compilation.compilerOptions.options + options.suppressWarnings.set(true) + options.freeCompilerArgs.apply { + add("-Xskip-prerelease-check") + if (project.findProperty("enableMFVC") == "true") add("-Xvalue-classes") + if (target.name == "android" || target.name == "jvm") add("-Xno-param-assertions") + add("-opt-in=kotlinx.cinterop.ExperimentalForeignApi") + } + } + } +} + +fun Project.enableFeaturesOnAllTargets() { + fun KotlinTarget.configureTarget() { + AddFreeCompilerArgs.addFreeCompilerArgs(project, this) + } + + extensions.findByType(KotlinSingleTargetExtension::class.java)?.also { + it.target.configureTarget() + } + + extensions.findByType(KotlinMultiplatformExtension::class.java)?.also { + it.targets.configureEach { target -> + target.configureTarget() + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/Android.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/Android.kt new file mode 100644 index 0000000000..d2103e720c --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/Android.kt @@ -0,0 +1,162 @@ +package korlibs.korge.gradle.targets.android + +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.jetbrains.kotlin.gradle.dsl.* +import java.io.* + +interface AndroidSdkProvider { + val projectDir: File + val androidSdkPath: String + val spawnExt: SpawnExtension + + fun execLogger(vararg params: String, filter: Process.(line: String) -> String? = { it }) { + spawnExt.execLogger(projectDir, *params, filter = filter) + } + + fun execOutput(vararg params: String): String { + return spawnExt.execOutput(projectDir, *params) + } +} + +val Project.androidSdkProvider: AndroidSdkProvider get() = object : AndroidSdkProvider { + override val projectDir: File get() = this@androidSdkProvider.projectDir + override val androidSdkPath: String get() = this@androidSdkProvider.androidSdkPath + override val spawnExt: SpawnExtension get() = this@androidSdkProvider.spawnExt +} + +val Project.androidSdkPath: String get() = AndroidSdk.getAndroidSdkPath(this) + +val AndroidSdkProvider.androidAdbPath get() = "$androidSdkPath/platform-tools/adb" +val AndroidSdkProvider.androidEmulatorPath get() = "$androidSdkPath/emulator/emulator" + +fun AndroidSdkProvider.execAndroidAdb(vararg args: String) { + execLogger(androidAdbPath, *args) +} + +fun AndroidSdkProvider.androidAdbDeviceList(): List { + return execOutput(androidAdbPath, "devices", "-l").trim().split("\n").map { it.trim() }.drop(1) + +} + +fun AndroidSdkProvider.androidEmulatorListAvds(): List { + val output = execOutput(androidEmulatorPath, "-list-avds").trim() + return when { + output.isBlank() -> listOf() + else -> output.split("\n").map { it.trim() } + } +} + +fun AndroidSdkProvider.androidEmulatorIsStarted(): Boolean { + return androidAdbDeviceList().any { it.contains("emulator") } +} + +fun AndroidSdkProvider.androidEmulatorFirstAvd(): String? { + val avds = androidEmulatorListAvds() + return avds.firstOrNull { !it.contains("_TV") } ?: avds.firstOrNull() +} + +fun AndroidSdkProvider.execAndroidEmulator(vararg args: String) { + execLogger(androidEmulatorPath, *args) +} + +fun Project.ensureAndroidLocalPropertiesWithSdkDir(outputFolder: File = project.rootDir) { + val path = project.tryToDetectAndroidSdkPath() + if (path != null) { + val localProperties = File(outputFolder, "local.properties") + if (!localProperties.exists()) { + localProperties + .ensureParents() + .writeText("sdk.dir=${path.absolutePath.replace("\\", "/")}") + } + } +} + + +fun AndroidSdkProvider.androidEmulatorStart() { + val avdName = androidEmulatorFirstAvd() ?: error("No android emulators available to start. Please create one using Android Studio") + val spawner = spawnExt + spawner.spawn(projectDir, listOf(androidEmulatorPath, "-avd", avdName, "-netdelay", "none", "-netspeed", "full")) + while (!androidEmulatorIsStarted()) { + Thread.sleep(1000L) + } +} + +/* +fun Project.androidGetResourcesFolders(): Pair, List> { + val targets = listOf(kotlin.metadata()) + val mainSourceSets = targets.flatMap { it.compilations["main"].allKotlinSourceSets } + + val resourcesSrcDirsBase = mainSourceSets.flatMap { it.resources.srcDirs } + listOf(file("src/androidMain/resources"))//, file("src/main/resources")) + //val resourcesSrcDirsBundle = project.korge.bundles.getPaths("android", resources = true, test = false) + //val resourcesSrcDirs = resourcesSrcDirsBase + resourcesSrcDirsBundle + val resourcesSrcDirs = resourcesSrcDirsBase + + val kotlinSrcDirsBase = mainSourceSets.flatMap { it.kotlin.srcDirs } + listOf(file("src/androidMain/kotlin"))//, file("src/main/java")) + //val kotlinSrcDirsBundle = project.korge.bundles.getPaths("android", resources = false, test = false) + //val kotlinSrcDirs = kotlinSrcDirsBase + kotlinSrcDirsBundle + val kotlinSrcDirs = kotlinSrcDirsBase + + return Pair(resourcesSrcDirs, kotlinSrcDirs) +} + +fun isKorlibsDependency(cleanFullName: String): Boolean { + if (cleanFullName.startsWith("org.jetbrains")) return false + if (cleanFullName.startsWith("junit:junit")) return false + if (cleanFullName.startsWith("org.hamcrest:hamcrest-core")) return false + if (cleanFullName.startsWith("org.jogamp")) return false + return true +} +*/ + +//fun writeAndroidManifest(outputFolder: File, korge: KorgeExtension, info: AndroidInfo = AndroidInfo(null)) { +// val generated = AndroidGenerated(korge, info) +// +// generated.writeKeystore(outputFolder) +// val srcMain = "src/androidMain" +// generated.writeAndroidManifest(File(outputFolder, srcMain)) +// generated.writeResources(File(outputFolder, "$srcMain/res")) +// generated.writeMainActivity(File(outputFolder, "$srcMain/kotlin")) +//} + +private var _tryAndroidSdkDirs: List? = null +// @TODO: Use [AndroidSdk] class +val tryAndroidSdkDirs: List get() { + if (_tryAndroidSdkDirs == null) { + _tryAndroidSdkDirs = listOf( + File(System.getProperty("user.home"), "/Library/Android/sdk"), // MacOS + File(System.getProperty("user.home"), "/Android/Sdk"), // Linux + File(System.getProperty("user.home"), "/AppData/Local/Android/Sdk") // Windows + ) + } + return _tryAndroidSdkDirs!! +} + +fun Project.tryToDetectAndroidSdkPath(): File? { + for (tryAndroidSdkDirs in tryAndroidSdkDirs) { + if (tryAndroidSdkDirs.exists()) { + return tryAndroidSdkDirs.absoluteFile + } + } + return null +} + +fun Project.getAndroidMinSdkVersion(): Int = project.findProperty("android.min.sdk.version")?.toString()?.toIntOrNull() ?: ANDROID_DEFAULT_MIN_SDK +fun Project.getAndroidCompileSdkVersion(): Int = project.findProperty("android.compile.sdk.version")?.toString()?.toIntOrNull() ?: ANDROID_DEFAULT_COMPILE_SDK +fun Project.getAndroidTargetSdkVersion(): Int = project.findProperty("android.target.sdk.version")?.toString()?.toIntOrNull() ?: ANDROID_DEFAULT_TARGET_SDK + +// https://apilevels.com/ +//const val ANDROID_DEFAULT_MIN_SDK = 16 // Previously 18 +//const val ANDROID_DEFAULT_MIN_SDK = 18 +const val ANDROID_DEFAULT_MIN_SDK = 21 // Android 5.0 +const val ANDROID_DEFAULT_COMPILE_SDK = 33 +const val ANDROID_DEFAULT_TARGET_SDK = 33 + +//val GRADLE_JAVA_VERSION_STR = "11" +val GRADLE_JAVA_VERSION_STR = "21" + +//val ANDROID_JAVA_VERSION = JavaVersion.VERSION_1_8 +//val ANDROID_JAVA_VERSION = JavaVersion.VERSION_11 +val ANDROID_JAVA_VERSION = JavaVersion.VERSION_21 +val ANDROID_JAVA_VERSION_STR = ANDROID_JAVA_VERSION.toString() +val ANDROID_JVM_TARGET = JvmTarget.fromTarget(ANDROID_JAVA_VERSION_STR) diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidConfig.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidConfig.kt new file mode 100644 index 0000000000..5a7975f195 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidConfig.kt @@ -0,0 +1,130 @@ +package korlibs.korge.gradle.targets.android + +import korlibs.korge.gradle.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.all.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import java.io.* + + +class AndroidInfo(val map: Map?) { + //init { println("AndroidInfo: $map") } + val androidInit: List = (map?.get("androidInit") as? List?)?.filterNotNull() ?: listOf() + val androidManifest: List = (map?.get("androidManifest") as? List?)?.filterNotNull() ?: listOf() + val androidDependencies: List = (map?.get("androidDependencies") as? List?)?.filterNotNull() ?: listOf() +} + +/* +fun Project.toAndroidConfig(): AndroidConfig = AndroidConfig.fromProject(this) +class AndroidConfig( + val buildDir: File, + val id: String, + val name: String, +) { + companion object { + fun fromProject(project: Project): AndroidConfig = AndroidConfig( + buildDir = project.buildDir, + id = project.korge.id, + name = project.name, + ) + } +} + + */ + +fun Project.toAndroidGenerated(isKorge: Boolean, info: AndroidInfo = AndroidInfo(null)): AndroidGenerated = AndroidGenerated( + icons = if (isKorge) korge.iconProvider else KorgeIconProvider(File(korgeGradlePluginResources, "icons/korge.png"), File(korgeGradlePluginResources, "banners/korge.png")), + ifNotExists = if (isKorge) korge.overwriteAndroidFiles else true, + androidPackageName = AndroidGenerated.getAppId(this, isKorge), + androidInit = korge.plugins.pluginExts.getAndroidInit() + info.androidInit, + androidMsaa = if (isKorge) korge.androidMsaa else 4, + fullscreen = if (isKorge) korge.fullscreen else true, + orientation = korge.orientation, + displayCutout = if (isKorge) korge.displayCutout else DisplayCutout.SHORT_EDGES, + realEntryPoint = if (isKorge) korge.realEntryPoint else "main", + androidAppName = korge.name, + androidManifestChunks = korge.androidManifestChunks, + androidManifestApplicationChunks = korge.androidManifestApplicationChunks, + androidManifest = korge.plugins.pluginExts.getAndroidManifestApplication() + info.androidManifest, + androidLibrary = if (isKorge) korge.androidLibrary else false, + androidCustomApplicationAttributes = korge.androidCustomApplicationAttributes, + projectName = project.name, + buildDir = project.buildDir, +) + +data class AndroidGenerated constructor( + val icons: KorgeIconProvider, + val ifNotExists: Boolean, + val androidPackageName: String, + val realEntryPoint: String = "main", + val androidMsaa: Int? = null, + val fullscreen: Boolean? = null, + val androidInit: List = emptyList(), + val orientation: Orientation = Orientation.DEFAULT, + val displayCutout: DisplayCutout = DisplayCutout.DEFAULT, + val androidAppName: String = "androidAppName", + val androidManifestChunks: Set = emptySet(), + val androidManifestApplicationChunks: Set = emptySet(), + val androidManifest: List = emptyList(), + val androidCustomApplicationAttributes: Map = emptyMap(), + val androidLibrary: Boolean = true, + val projectName: String, + val buildDir: File, +) { + fun writeResources(folder: File) { + writeFileBytes(File(folder, "mipmap-mdpi/icon.png")) { icons.getIconBytes() } + writeFileBytes(File(folder, "drawable/app_icon.png")) { icons.getIconBytes() } + writeFileBytes(File(folder, "drawable/app_banner.png")) { icons.getBannerBytes(432, 243) } + writeFileText(File(folder, "values/styles.xml")) { AndroidManifestXml.genStylesXml(this@AndroidGenerated) } + } + + fun writeMainActivity(outputFolder: File) { + writeFileText(File(outputFolder, "MainActivity.kt")) { AndroidMainActivityKt.genAndroidMainActivityKt(this@AndroidGenerated) } + } + + fun writeAndroidManifest(outputFolder: File) { + writeFileText(File(outputFolder, "AndroidManifest.xml")) { AndroidManifestXml.genAndroidManifestXml(this@AndroidGenerated) } + } + + fun writeKeystore(outputFolder: File) { + writeFileBytes(File(outputFolder, "korge.keystore")) { getResourceBytes("korge.keystore") } + } + + private fun writeFileBytes(file: File, gen: () -> ByteArray) { + file.conditionally(ifNotExists) { ensureParents().writeBytesIfChanged(gen()) } + } + private fun writeFileText(file: File, gen: () -> String) { + file.conditionally(ifNotExists) { ensureParents().writeTextIfChanged(gen()) } + } + + fun getAppId(isKorge: Boolean): String { + return if (isKorge) androidPackageName else "korlibs.${projectName.replace("-", "_")}" + //val namespace = "com.soywiz.${project.name.replace("-", ".")}" + } + + fun getNamespace(isKorge: Boolean): String { + //return if (isKorge) project.korge.id else "com.soywiz.${project.name.replace("-", ".")}" + return getAppId(isKorge) + } + + fun getAndroidManifestFile(isKorge: Boolean): File { + //return File(project.projectDir, "src/androidMain/AndroidManifest.xml") + return File(buildDir, "AndroidManifest.xml") + } + + fun getAndroidResFolder(isKorge: Boolean): File { + //return File(project.projectDir, "src/androidMain/res") + return File(buildDir, "platforms/android/androires").ensureParents() + } + fun getAndroidSrcFolder(isKorge: Boolean): File { + //return File(project.projectDir, "src/androidMain/kotlin") + return File(buildDir, "platforms/android/androisrc").ensureParents() + } + + companion object { + fun getAppId(project: Project, isKorge: Boolean): String { + return if (isKorge) project.korge.id else "korlibs.${project.name.replace("-", "_")}" + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidDirect.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidDirect.kt new file mode 100644 index 0000000000..9f41b4ccc6 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidDirect.kt @@ -0,0 +1,231 @@ +package korlibs.korge.gradle.targets.android + +import com.android.build.api.dsl.* +import com.android.build.gradle.TestedExtension +import com.android.build.gradle.tasks.* +import korlibs.* +import korlibs.korge.gradle.* +import korlibs.korge.gradle.kotlin +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.all.* +import korlibs.korge.gradle.targets.jvm.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.tasks.* +import org.gradle.configurationcache.extensions.* +import java.io.* + +fun Project.configureAndroidDirect(projectType: ProjectType, isKorge: Boolean) { + if (!AndroidSdk.hasAndroidSdk(this)) { + logger.info("Couldn't find ANDROID SDK, do not configuring android") + return + } + + project.ensureAndroidLocalPropertiesWithSdkDir() + + if (projectType.isExecutable) { + project.plugins.apply("com.android.application") + } else { + plugins.apply("com.android.library") + } + + //val android = project.extensions.getByName("android") + //project.kotlin.jvmToolchain(11) + + project.kotlin.androidTarget().apply { + //project.kotlin.android().apply { + publishAllLibraryVariants() + publishLibraryVariantsGroupedByFlavor = true + //this.attributes.attribute(KotlinPlatformType.attribute, KotlinPlatformType.androidJvm) + compilations.allThis { + kotlinOptions.jvmTarget = ANDROID_JAVA_VERSION_STR + } + AddFreeCompilerArgs.addFreeCompilerArgs(project, this) + } + + ensureSourceSetsConfigure("common", "android") + + //if (isKorge) { + // project.afterEvaluate { + // //println("@TODO: Info is not generated") + // //writeAndroidManifest(project.rootDir, project.korge) + // } + //} + //val generated = AndroidGenerated(korge) + + dependencies { + if (SemVer(BuildVersions.KOTLIN) >= SemVer("1.9.0")) { + add("androidUnitTestImplementation", "org.jetbrains.kotlin:kotlin-test") + } + add("androidTestImplementation", "org.jetbrains.kotlin:kotlin-test") + + add("androidTestImplementation", "org.jetbrains.kotlin:kotlin-test") + add("androidTestImplementation", "androidx.test:core:1.4.0") + add("androidTestImplementation", "androidx.test.ext:junit:1.1.2") + add("androidTestImplementation", "androidx.test.espresso:espresso-core:3.3.0") + //androidTestImplementation 'com.android.support.test:runner:1.0.2' + } + + val android = extensions.getByName("android") + android.apply { + val androidGenerated = project.toAndroidGenerated(isKorge) + namespace = androidGenerated.getNamespace(isKorge) + + setCompileSdkVersion(if (isKorge) project.korge.androidCompileSdk else project.getAndroidCompileSdkVersion()) + //buildToolsVersion(project.findProperty("android.buildtools.version")?.toString() ?: "30.0.2") + + (this as CommonExtension<*, *, *, *, *>).installation.apply { + // @TODO: Android Build Gradle newer version + installOptions("-r") + timeOutInMs = project.korge.androidTimeoutMs + } + + compileOptions.apply { + sourceCompatibility = ANDROID_JAVA_VERSION + targetCompatibility = ANDROID_JAVA_VERSION + } + + lintOptions.apply { + checkOnly() + //checkReleaseBuilds = false + } + + buildFeatures.apply { + if (project.name == "korlibs-platform") { + buildConfig = true + } + } + + packagingOptions.also { + for (pattern in when { + isKorge -> project.korge.androidExcludePatterns + else -> KorgeExtension.DEFAULT_ANDROID_EXCLUDE_PATTERNS - KorgeExtension.DEFAULT_ANDROID_INCLUDE_PATTERNS_LIBS + }) { + it.resources.excludes.add(pattern) + } + } + + defaultConfig.also { + it.multiDexEnabled = true + if (projectType.isExecutable) { + it.applicationId = androidGenerated.getAppId(isKorge) + } + it.minSdk = if (isKorge) project.korge.androidMinSdk else project.getAndroidMinSdkVersion() + it.targetSdk = if (isKorge) project.korge.androidTargetSdk else project.getAndroidTargetSdkVersion() + it.versionCode = if (isKorge) project.korge.versionCode else 1 + it.versionName = if (isKorge) project.korge.version else "1.0" + it.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + it.manifestPlaceholders.clear() + it.manifestPlaceholders.putAll(if (isKorge) korge.configs else emptyMap()) + } + + lintOptions.also { + // @TODO: ../../build.gradle: All com.android.support libraries must use the exact same version specification (mixing versions can lead to runtime crashes). Found versions 28.0.0, 26.1.0. Examples include com.android.support:animated-vector-drawable:28.0.0 and com.android.support:customtabs:26.1.0 + it.disable("GradleCompatible") + } + + signingConfigs.apply { + maybeCreate("release").apply { + storeFile = if (isKorge) project.file(project.findProperty("RELEASE_STORE_FILE") ?: korge.androidReleaseSignStoreFile) else File(korgeGradlePluginResources, "korge.keystore") + storePassword = project.findProperty("RELEASE_STORE_PASSWORD")?.toString() ?: (if (isKorge) korge.androidReleaseSignStorePassword else "password") + keyAlias = project.findProperty("RELEASE_KEY_ALIAS")?.toString() ?: (if (isKorge) korge.androidReleaseSignKeyAlias else "korge") + keyPassword = project.findProperty("RELEASE_KEY_PASSWORD")?.toString() ?: (if (isKorge) korge.androidReleaseSignKeyPassword else "password") + } + } + buildTypes.apply { + //this.single(). + if (projectType.isExecutable) { + maybeCreate("debug").apply { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = false + } + } + maybeCreate("release").apply { + signingConfig = signingConfigs.getByName("release") + if (projectType.isExecutable) { + isMinifyEnabled = true // for libraries, this would make the library to be empty + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + File(rootDir, "proguard-rules.pro").takeIfExists()?.also { + proguardFile(it) + } + //proguardFiles(getDefaultProguardFile(ProguardFiles.ProguardFile.OPTIMIZE.fileName), File(rootProject.rootDir, "proguard-rules.pro")) + } else { + isMinifyEnabled = false + } + } + } + + sourceSets.apply { + maybeCreate("main").apply { + //assets.srcDirs("src/commonMain/resources",) + //val (resourcesSrcDirs, kotlinSrcDirs) = androidGetResourcesFolders() + //println("@ANDROID_DIRECT:") + //println(resourcesSrcDirs.joinToString("\n")) + //println(kotlinSrcDirs.joinToString("\n")) + manifest.srcFile(androidGenerated.getAndroidManifestFile(isKorge = isKorge)) + java.srcDirs(androidGenerated.getAndroidSrcFolder(isKorge = isKorge)) + res.srcDirs(androidGenerated.getAndroidResFolder(isKorge = isKorge)) + assets.srcDirs( + "${project.buildDir}/processedResources/jvm/main", + //"${project.projectDir}/src/commonMain/resources", + //"${project.projectDir}/src/androidMain/resources", + //"${project.projectDir}/src/main/resources", + //"${project.projectDir}/build/commonMain/korgeProcessedResources/metadata/main", + //"${project.projectDir}/build/korgeProcessedResources/android/main", + ) + //assets.srcDirs(*resourcesSrcDirs.map { it.absoluteFile }.toTypedArray()) + //java.srcDirs(*kotlinSrcDirs.map { it.absoluteFile }.toTypedArray()) + //manifest.srcFile(File(project.buildDir, "AndroidManifest.xml")) + //manifest.srcFile(File(project.projectDir, "src/main/AndroidManifest.xml")) + } + for (name in listOf("test", "testDebug", "testRelease", "androidTest", "androidTestDebug", "androidTestRelease")) { + maybeCreate(name).apply { + assets.srcDirs("src/commonTest/resources") + } + } + } + } + + if (projectType.isExecutable) { + installAndroidRun(listOf(), direct = true, isKorge = isKorge) + } + + afterEvaluate { + val jvmProcessResources = tasks.findByName("jvmProcessResources") as? Copy? + if (jvmProcessResources != null) { + jvmProcessResources.duplicatesStrategy = org.gradle.api.file.DuplicatesStrategy.INCLUDE + val packageDebugAssets = tasks.findByName("packageDebugAssets") as MergeSourceSetFolders? + val packageReleaseAssets = tasks.findByName("packageReleaseAssets") as MergeSourceSetFolders? + + // @TODO: Why is this required with Gradle 8.1.1? + //println("${project.path} :: $packageDebugAssets dependsOn $jvmProcessResources") + packageDebugAssets?.dependsOn(jvmProcessResources) // @TODO: <-- THIS + packageReleaseAssets?.dependsOn(jvmProcessResources) // @TODO: <-- THIS + + // @TODO: Why is this required with Gradle 8.1.1? + //packageDebugAssets?.mustRunAfter(jvmProcessResources) // @TODO: <-- THIS + //packageReleaseAssets?.mustRunAfter(jvmProcessResources) // @TODO: <-- THIS + } + val compileDebugJavaWithJavac = project.tasks.findByName("compileDebugJavaWithJavac") as? org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile? + compileDebugJavaWithJavac?.compilerOptions?.jvmTarget?.set(ANDROID_JVM_TARGET) + + for (kind in listOf("debug", "release")) { + val kindCap = kind.capitalized() + tasks.create("packageAndroid$kindCap", Task::class.java) { + it.dependsOn("bundle$kindCap") + it.group = GROUP_KORGE_PACKAGE + it.description = "Creates an AAB $kind file in the `build/outputs/bundle/$kind` folder (replaces APK)" + } + } + + //tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile::class.java).configureEach { + // it.compilerOptions.jvmTarget.set(ANDROID_JVM_TARGET) + // //it.jvmTargetValidationMode.set(org.jetbrains.kotlin.gradle.dsl.jvm.JvmTargetValidationMode.WARNING) + //} + //val compileDebugJavaWithJavac = tasks.findByName("compileDebugJavaWithJavac") + //println("compileDebugJavaWithJavac=$compileDebugJavaWithJavac : ${compileDebugJavaWithJavac!!::class}") + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidMainActivityKt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidMainActivityKt.kt new file mode 100644 index 0000000000..eea06f9fab --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidMainActivityKt.kt @@ -0,0 +1,24 @@ +package korlibs.korge.gradle.targets.android + +import korlibs.korge.gradle.util.* + +object AndroidMainActivityKt { + fun genAndroidMainActivityKt(config: AndroidGenerated): String = Indenter { + line("package ${config.androidPackageName}") + + //line("import korlibs.io.android.withAndroidContext") + line("import korlibs.render.*") + line("import ${config.realEntryPoint}") + + line("class MainActivity : KorgwActivity(config = GameWindowCreationConfig(msaa = ${config.androidMsaa ?: 1}, fullscreen = ${config.fullscreen}))") { + line("override suspend fun activityMain()") { + //line("withAndroidContext(this)") { // @TODO: Probably we should move this to KorgwActivity itself + for (text in config.androidInit) { + line(text) + } + line("${config.realEntryPoint}()") + //} + } + } + }.toString() +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidManifestXml.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidManifestXml.kt new file mode 100644 index 0000000000..51978bc7ff --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidManifestXml.kt @@ -0,0 +1,117 @@ +package korlibs.korge.gradle.targets.android + +import korlibs.korge.gradle.* +import korlibs.korge.gradle.util.* + +object AndroidManifestXml { + fun genStylesXml(config: AndroidGenerated): String = Indenter { + line("") + line("") + indent { + line("") + } + line("") + } + + fun genAndroidManifestXml(config: AndroidGenerated): String = Indenter { + line("") + line("") + indent { + line("") + line("") + + line("") + indent { + line("") + for (text in config.androidManifest) { + line(text) + } + for (text in config.androidManifestApplicationChunks) { + line(text) + } + + line(" "landscape" + Orientation.PORTRAIT -> "portrait" + Orientation.DEFAULT -> "sensor" + } + line("android:banner=\"@drawable/app_banner\"") + line("android:icon=\"@drawable/app_icon\"") + line("android:label=\"${config.androidAppName}\"") + line("android:logo=\"@drawable/app_icon\"") + line("android:configChanges=\"orientation|screenSize|screenLayout|keyboardHidden\"") + line("android:screenOrientation=\"$orientationString\"") + line("android:exported=\"true\"") + } + line(">") + + if (!config.androidLibrary) { + indent { + line("") + indent { + line("") + line("") + } + line("") + } + } + line("") + } + line("") + for (text in config.androidManifestChunks) { + line(text) + } + } + line("") + }.toString() +} + +private fun String.htmlspecialchars(): String = buildString(this@htmlspecialchars.length + 16) { + for (it in this@htmlspecialchars) { + when (it) { + '"' -> append(""") + '\'' -> append("'") + '<' -> append("<") + '>' -> append(">") + '&' -> append("&") + else -> append(it) + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidRun.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidRun.kt new file mode 100644 index 0000000000..cff894c2b8 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidRun.kt @@ -0,0 +1,312 @@ +package korlibs.korge.gradle.targets.android + +import com.android.build.gradle.internal.lint.* +import korlibs.korge.gradle.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.util.* +import korlibs.korge.gradle.util.AnsiEscape.Companion.color +import org.gradle.api.* +import org.gradle.api.tasks.* +import org.jetbrains.kotlin.gradle.dsl.* +import java.io.* + +fun Project.installAndroidRun(dependsOnList: List, direct: Boolean, isKorge: Boolean) { + if (!AndroidSdk.hasAndroidSdk(this)) { + logger.info("Not configuring android because couldn't find the SDK") + return + } + + val createAndroidManifest = tasks.createThis("createAndroidManifest") { + this.isKorge = isKorge + } + + val hasKotlinMultiplatformExtension = project.extensions.findByType(KotlinMultiplatformExtension::class.java) != null + if (hasKotlinMultiplatformExtension) { + afterEvaluate { + //generateKorgeProcessedFromTask(null, "androidProcessResources") + } + } + + //val generateAndroidProcessedResources = getKorgeProcessResourcesTaskName("jvm", "main") + val generateAndroidProcessedResources = getProcessResourcesTaskName("jvm", "main") + + afterEvaluate { + for (Type in listOf("Debug", "Release")) { + //println("tasks.findByName(\"generate${Type}Assets\")=${tasks.findByName("generate${Type}Assets")}") + //println("tasks.findByName(\"package${Type}\")=${tasks.findByName("package${Type}")}") + if (hasKotlinMultiplatformExtension) { + tasks.findByName("generate${Type}Assets")?.dependsOn(generateAndroidProcessedResources) + tasks.findByName("package${Type}")?.dependsOn(generateAndroidProcessedResources) + } + + tasks.findByName("generate${Type}BuildConfig")?.dependsOn(createAndroidManifest) + tasks.findByName("process${Type}MainManifest")?.dependsOn(createAndroidManifest) + tasks.findByName("process${Type}Resources")?.dependsOn(createAndroidManifest) + + + // Not required anymore + //(tasks.getByName("install${Type}") as InstallVariantTask).apply { installOptions = listOf("-r") } + //tasks.getByName("install${Type}").dependsOn("createAndroidManifest") + } + } + + // adb shell am start -n com.package.name/com.package.name.ActivityName + for (debug in listOf(false, true)) { + val suffixDebug = if (debug) "Debug" else "Release" + + for (emulator in listOf(null, false, true)) { + val suffixDevice = when (emulator) { + null -> "" + false -> "Device" + true -> "Emulator" + } + + val extra = when (emulator) { + null -> arrayOf() + false -> arrayOf("-d") + true -> arrayOf("-e") + } + + val installAndroidTaskName = "installAndroid$suffixDevice$suffixDebug" + val installAndroidTask = when { + direct -> tasks.createThis(installAndroidTaskName) { + //task.dependsOn("install$suffixDevice$suffixDebug") + dependsOn("install$suffixDebug") + } + + else -> tasks.createThis(installAndroidTaskName) { + buildFile = File(buildDir, "platforms/android/build.gradle") + //task.version = "4.10.1" + //task.tasks = listOf("install$suffixDevice$suffixDebug") + tasks = listOf("install$suffixDebug") + } + } + + if (emulator == true) { + installAndroidTask.dependsOn("androidEmulatorStart") + } + for (dependsOnTaskName in dependsOnList) { + installAndroidTask.dependsOn(dependsOnTaskName) + } + installAndroidTask.group = GROUP_KORGE_INSTALL + + //installAndroidTask.dependsOn(getKorgeProcessResourcesTaskName("jvm", "main")) + //installAndroidTask.dependsOn(getKorgeProcessResourcesTaskName("metadata", "main")) + + val androidApplicationId = AndroidGenerated.getAppId(project, isKorge) + + val onlyRunAndroid = tasks.createTyped("onlyRunAndroid$suffixDevice$suffixDebug") { + this.extra = extra + } + + afterEvaluate { + onlyRunAndroid.androidApplicationId = AndroidGenerated.getAppId(project, isKorge) + } + + tasks.createTyped("runAndroid$suffixDevice$suffixDebug") { + group = GROUP_KORGE_RUN + dependsOn(ordered("createAndroidManifest", installAndroidTaskName)) + finalizedBy(onlyRunAndroid) + } + + } + } + + tasks.createTyped("androidEmulatorDeviceList") { + group = GROUP_KORGE_ADB + } + + tasks.createTyped("androidEmulatorStart") { + group = GROUP_KORGE_ADB + onlyIf { !androidEmulatorIsStarted() } + } + + tasks.createTyped("adbDeviceList") { + group = GROUP_KORGE_ADB + } + + tasks.createTyped("adbLogcat") { + group = GROUP_KORGE_ADB + } + + afterEvaluate { + afterEvaluate { + listOfNotNull( + tasks.findByName("generateReleaseLintVitalReportModel"), + tasks.findByName("generateDebugLintVitalReportModel") + ).forEach { + it.dependsOn("jvmProcessResources") + it.enabled = false + } + } + } +} + +open class AndroidCreateAndroidManifest : DefaultTask() { + private lateinit var generated: AndroidGenerated + + @get:Input + var isKorge = true + + init { + project.afterEvaluate { + generated = project.toAndroidGenerated(isKorge) + } + } + @TaskAction + fun run() { + //println("this.generated=${this.generated} : isKorge=$isKorge") + val mainDir = this.generated.getAndroidManifestFile(isKorge).parentFile + generated.writeResources(this.generated.getAndroidResFolder(isKorge)) + generated.writeMainActivity(this.generated.getAndroidSrcFolder(isKorge)) + generated.writeKeystore(mainDir) + generated.writeAndroidManifest(mainDir) + } +} + +open class OnlyRunAndroidTask : DefaultAndroidTask() { + @get:Input + var extra: Array = emptyArray() + @get:Input + var androidApplicationId: String = "" + + override fun run() { + execLogger(androidAdbPath, *extra, "shell", "am", "start", + "-e", "sleepBeforeStart", "300", + "-n", "$androidApplicationId/$androidApplicationId.MainActivity" + ) + val pid: String = run { + val startTime = System.currentTimeMillis() + while (true) { + val currentTime = System.currentTimeMillis() + val elapsedTime = currentTime - startTime + try { + val res = execOutput(androidAdbPath, *extra, "shell", "pidof", androidApplicationId).trim() + if (res.isEmpty()) error("PID not found") + return@run res + } catch (e: Throwable) { + //e.printStackTrace() + Thread.sleep(10L) + if (elapsedTime >= 5000L) throw e + } + } + error("Unexpected") + } + val EXIT_MESSAGE = "InputTransport: Input channel destroyed:" + execLogger(androidAdbPath, *extra, "logcat", "--pid=$pid") { + if (it.contains(EXIT_MESSAGE)) { + println("Found EXIT_MESSAGE=$EXIT_MESSAGE") + this.destroy() + } + val parts = it.split(" ", limit = 5) + val end = parts.getOrElse(4) { " " }.trimStart() + val color = when { + end.startsWith('V') -> null + end.startsWith('D') -> AnsiEscape.Color.BLUE + end.startsWith('I') -> AnsiEscape.Color.GREEN + end.startsWith('W') -> AnsiEscape.Color.YELLOW + end.startsWith('E') -> AnsiEscape.Color.RED + else -> AnsiEscape.Color.WHITE + } + //println("parts=$parts") + if (color != null) it.color(color) else it + } + } +} + +open class AndroidEmulatorListAvdsTask : DefaultAndroidTask() { + override fun run() { + androidEmulatorListAvds().joinToString("\n") + } +} + +open class AndroidAdbDeviceListTask : DefaultAndroidTask() { + override fun run() { + println(androidAdbDeviceList().joinToString("\n")) + //execAndroidAdb("devices", "-l") + } +} + +open class AndroidAdbLogcatTask : DefaultAndroidTask() { + override fun run() { + execAndroidAdb("logcat") + } +} + +open class AndroidEmulatorStartTask : DefaultAndroidTask() { + override fun run() { + val avdName = androidEmulatorFirstAvd() ?: error("No android emulators available to start. Please create one using Android Studio") + val spawner = spawnExt + spawner.spawn(projectDir, listOf(androidEmulatorPath, "-avd", avdName, "-netdelay", "none", "-netspeed", "full")) + while (!androidEmulatorIsStarted()) { + Thread.sleep(1000L) + } + } +} + + +abstract class DefaultAndroidTask : DefaultTask(), AndroidSdkProvider { + @TaskAction + abstract fun run() + + //@get:InputDirectory + @Internal + override lateinit var projectDir: File + //@get:Input + @Internal + override lateinit var androidSdkPath: String + //@get:Input + @Internal + override lateinit var spawnExt: SpawnExtension + + fun initWithProject(project: Project) { + this.projectDir = project.projectDir + this.androidSdkPath = project.androidSdkPath + this.spawnExt = project.spawnExt + } + + init { + project.afterEvaluate { + initWithProject(it) + } + } +} + +/* +tasks.createThis("onlyRunAndroid") { + doFirst { + val adb = "${AndroidSdk.guessAndroidSdkPath()}/platform-tools/adb" + execThis { + commandLine(adb, "shell", "am", "start", "-n", "${androidApplicationId}/${androidApplicationId}.MainActivity") + } + + var pid = "" + for (n in 0 until 10) { + try { + pid = execOutput(adb, "shell", "pidof", androidApplicationId) + break + } catch (e: Throwable) { + Thread.sleep(500L) + if (n == 9) throw e + } + } + println(pid) + execThis { + commandLine(adb, "logcat", "--pid=${pid.trim()}") + } + } +} + +afterEvaluate { + tasks.createThis("runAndroidDebug") { + dependsOn(ordered("createAndroidManifest", "installDebug")) + finalizedBy("onlyRunAndroid") + } + + tasks.createThis("runAndroidRelease") { + dependsOn(ordered("createAndroidManifest", "installRelease")) + finalizedBy("onlyRunAndroid") + } +} + + */ diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidSdk.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidSdk.kt new file mode 100644 index 0000000000..b823638479 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/android/AndroidSdk.kt @@ -0,0 +1,59 @@ +package korlibs.korge.gradle.targets.android + +import org.gradle.api.* +import java.io.* +import java.util.* + +object AndroidSdk { + val ANDROID_SDK_PATH_KEY = "android.sdk.path" + + //Linux: ~/Android/Sdk + //Mac: ~/Library/Android/sdk + //Windows: %LOCALAPPDATA%\Android\sdk + @JvmStatic + fun getAndroidSdkPath(project: Project): String { + return _getAndroidSdkOrNullCreateLocalProperties(project) + ?: error("Can't find android sdk (ANDROID_HOME environment not set and Android SDK not found in standard locations)") + } + + @JvmStatic + fun hasAndroidSdk(project: Project): Boolean { + return _getAndroidSdkOrNullCreateLocalProperties(project) != null + } + + @JvmStatic + private fun _getAndroidSdkOrNullCreateLocalProperties(project: Project): String? { + // Project property (assume it exists without checking because tests require it) + val extensionAndroidSdkPath = ( + project.findProperty(ANDROID_SDK_PATH_KEY)?.toString() ?: project.extensions.findByName(ANDROID_SDK_PATH_KEY)?.toString() + ) + if (extensionAndroidSdkPath != null) return extensionAndroidSdkPath + + val localPropertiesFile = File(project.rootProject.rootDir, "local.properties") + val props = Properties().apply { if (localPropertiesFile.exists()) load(localPropertiesFile.readText().reader()) } + if (props.getProperty("sdk.dir") != null) return props.getProperty("sdk.dir") + val sdk = __getAndroidSdkOrNull(project) ?: return null + props.setProperty("sdk.dir", sdk.replace("\\", "/")) + localPropertiesFile.writer().use { props.store(it, null) } + return sdk + } + + @JvmStatic + private fun __getAndroidSdkOrNull(project: Project): String? { + // Environment variable + val env = System.getenv("ANDROID_SDK_ROOT")?.takeIf { File(it).isDirectory } + if (env != null) return env + + // GUESS IT + val userHome = System.getProperty("user.home") + return listOfNotNull( + System.getenv("ANDROID_HOME"), + "$userHome/AppData/Local/Android/sdk", + "$userHome/Library/Android/sdk", + "$userHome/Android/Sdk", + "$userHome/AndroidSDK", // location of sdkmanager on linux + "/usr/lib/android-sdk", // location on debian based linux (sudo apt install android-sdk) + "/Library/Android/sdk" // some other flavor of linux + ).firstOrNull { File(it).isDirectory } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/apple/IcnsBuilder.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/apple/IcnsBuilder.kt new file mode 100644 index 0000000000..7d272d4029 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/apple/IcnsBuilder.kt @@ -0,0 +1,31 @@ +package korlibs.korge.gradle.targets.apple + +import korlibs.korge.gradle.util.encodePNG +import korlibs.korge.gradle.util.toBufferedImage +import java.awt.Image +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import javax.imageio.ImageIO + +object IcnsBuilder { + fun build(image: ByteArray): ByteArray { + return build(ImageIO.read(image.inputStream())) + } + + fun build(image: BufferedImage): ByteArray { + val scaledImage = image.getScaledInstance(512, 512, Image.SCALE_SMOOTH).toBufferedImage() + val ic09Bytes = scaledImage.encodePNG() + return ByteArrayOutputStream().also { baos -> + DataOutputStream(baos).apply { + writeBytes("icns") + writeInt(8 + 8 + ic09Bytes.size) + writeBytes("ic09") + writeInt(ic09Bytes.size) + write(ic09Bytes) + flush() + } + }.toByteArray() + } + +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/apple/InfoPlistBuilder.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/apple/InfoPlistBuilder.kt new file mode 100644 index 0000000000..ac372d2f55 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/apple/InfoPlistBuilder.kt @@ -0,0 +1,68 @@ +package korlibs.korge.gradle.targets.apple + +import korlibs.korge.gradle.* + +object InfoPlistBuilder { + fun GameCategory?.toUTI(): String { + return "public.app-category." + when (this) { + null -> "games" + GameCategory.ACTION -> "action-games" + GameCategory.ADVENTURE -> "adventure-games" + GameCategory.ARCADE -> "arcade-games" + GameCategory.BOARD -> "board-games" + GameCategory.CARD -> "card-games" + GameCategory.CASINO -> "casino-games" + GameCategory.DICE -> "dice-games" + GameCategory.EDUCATIONAL -> "educational-games" + GameCategory.FAMILY -> "family-games" + GameCategory.KIDS -> "kids-games" + GameCategory.MUSIC -> "music-games" + GameCategory.PUZZLE -> "puzzle-games" + GameCategory.RACING -> "racing-games" + GameCategory.ROLE_PLAYING -> "role-playing-games" + GameCategory.SIMULATION -> "simulation-games" + GameCategory.SPORTS -> "sports-games" + GameCategory.STRATEGY -> "strategy-games" + GameCategory.TRIVIA -> "trivia-games" + GameCategory.WORD -> "word-games" + } + } + + // https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html + // https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/LaunchServicesKeys.html + fun build(ext: KorgeExtension): String = buildString { + appendLine("""""") + appendLine("""""") + appendLine("""""") + run { + //appendLine(" BuildMachineOSBuild16G1510") + //appendLine(" LSUIElement1") + //appendLine(" DTSDKBuild14D125") + //appendLine(" DTSDKNamemacosx10.1010.10") + //appendLine(" DTXcode0833") + //appendLine(" DTXcodeBuild8E3004b") + //appendLine(" NSMainNibFileMainMenu") + //appendLine(" NSPrincipalClassMyApplication") + //appendLine(" NSSupportsAutomaticGraphicsSwitching") + + appendLine(" CFBundleDisplayName${ext.name}") + appendLine(" CFBundleExecutable${ext.exeBaseName}") + appendLine(" CFBundleIconFile${ext.exeBaseName}.icns") + appendLine(" CFBundleIdentifier${ext.id}") + appendLine(" CFBundleName${ext.name}") + appendLine(" CFBundleGetInfoString${ext.description}") + appendLine(" NSHumanReadableCopyright${ext.copyright}") + appendLine(" CFBundleVersion${ext.version}") + appendLine(" CFBundleShortVersionString${ext.version}") + appendLine(" LSApplicationCategoryType${ext.gameCategory.toUTI()}") + + appendLine(" CFBundleInfoDictionaryVersion6.0") + appendLine(" CFBundlePackageTypeAPPL") + appendLine(" CFBundleSignature????") + appendLine(" LSMinimumSystemVersion10.9.0") + appendLine(" NSHighResolutionCapable") + appendLine(" LSRequiresNativeExecution") + } + appendLine("""""") + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/desktop/DesktopCommon.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/desktop/DesktopCommon.kt new file mode 100644 index 0000000000..8b3f995b36 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/desktop/DesktopCommon.kt @@ -0,0 +1,44 @@ +package korlibs.korge.gradle.targets.desktop + +import korlibs.korge.gradle.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.io.* + +open class PrepareKotlinNativeBootstrapTask : DefaultTask() { + @get:OutputFile + val output = project.nativeDesktopBootstrapFile + + private var realEntryPoint: String = "InvalidClass" + + init { + project.afterEvaluate { + realEntryPoint = project.korge.realEntryPoint + } + } + + @TaskAction + fun run() { + output.parentFile.mkdirs() + + val text = Indenter { + //line("package korge.bootstrap") + line("import $realEntryPoint") + line("fun main(args: Array = arrayOf()): Unit = RootGameMain.runMain(args)") + line("object RootGameMain") { + line("fun runMain() = runMain(arrayOf())") + line("@Suppress(\"UNUSED_PARAMETER\") fun runMain(args: Array): Unit = korlibs.io.Korio { ${realEntryPoint}() }") + } + } + if (!output.exists() || output.readText() != text) output.writeText(text) + } +} + +val Project.prepareKotlinNativeBootstrap: Task get() { + val taskName = "prepareKotlinNativeBootstrap" + return tasks.findByName(taskName) ?: tasks.createThis(taskName) +} + +val Project.nativeDesktopBootstrapFile get() = File(buildDir, "platforms/native-desktop/bootstrap.kt") diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/desktop/DesktopJreBundler.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/desktop/DesktopJreBundler.kt new file mode 100644 index 0000000000..bf2e90c208 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/desktop/DesktopJreBundler.kt @@ -0,0 +1,168 @@ +package korlibs.korge.gradle.targets.desktop + +import korlibs.korge.gradle.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.apple.* +import korlibs.korge.gradle.targets.windows.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import java.io.* +import java.net.* +import java.security.* + +// https://stackoverflow.com/questions/13017121/unpacking-tar-gz-into-root-dir-with-gradle +// https://github.com/korlibs/universal-jre/ +object DesktopJreBundler { + // https://github.com/adoptium/temurin21-binaries/releases/tag/jdk-21%2B35 + + data class UrlRef(val url: String, val sha256: String) + + val JRE_WIN64_LAUNCHER = UrlRef( + "https://github.com/korlibs/universal-jre/releases/download/0.0.1/launcher-win.exe", + sha256 = "c0124f38329509145fbb278d3b592daa12bb556ab0341310d4f67b9eded0c270", + ) + + val JRE_MACOS_LAUNCHER = UrlRef( + "https://github.com/korlibs/universal-jre/releases/download/0.0.1/app", + sha256 = "4123b08e24678885781b04125675aa2f7d2af87583a753d16737ad154934bf0b", + ) + + val JRE_MACOS_UNIVERSAL = UrlRef( + "https://github.com/korlibs/universal-jre/releases/download/0.0.1/macos-universal-jdk-21+35-jre.tar.gz", + sha256 = "6d2d0a2e35c649fc731f5d3f38d7d7828f7fad4b9b2ea55d4d05f0fd26cf93ca", + ) + + val JRE_WIN64 = UrlRef( + "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21%2B35/OpenJDK21U-jre_x64_windows_hotspot_21_35.zip", + sha256 = "3753e9b1d7186191766954f7957cc0c3c4de9633366dbfdfa573e30b371b7ab7", + ) + + val LOCAL_JRE_DIR = File(korgeCacheDir, "jre") + val LOCAL_UNIVERSAL = File(LOCAL_JRE_DIR, "universal") + val LOCAL_WIN32 = File(LOCAL_JRE_DIR, "win32") + val LOCAL_UNIVERSAL_JRE = File(LOCAL_UNIVERSAL, "jdk-21+35-jre/Contents/jre") + val LOCAL_WIN32_JRE = File(LOCAL_WIN32, "jdk-21+35-jre") + + fun cachedFile(urlRef: UrlRef): File { + val downloadUrl = URL(urlRef.url) + val localFile = File(LOCAL_JRE_DIR, File(downloadUrl.file).name).ensureParents() + if (!localFile.isFile) { + println("Downloading $downloadUrl...") + val bytes = downloadUrl.readBytes() + val actualSha256 = MessageDigest.getInstance("SHA-256").digest(bytes).hex + val expectedSha256 = urlRef.sha256 + if (actualSha256 != expectedSha256) { + error("URL: ${urlRef.url} expected to have $expectedSha256 but was $actualSha256") + } + localFile.writeBytes(bytes) + } + return localFile + } + + fun createLinuxBundle(project: Project, fatJar: File) { + val gameApp = File(project.buildDir, "platforms/jvm-linux").also { it.mkdirs() } + + // TODO: Paths must be absolute. So this file just serves as reference + File(gameApp, "game.desktop").writeText(""" + [Desktop Entry] + Type=Application + Name=${project.korge.name} + Comment=Launch MyApp + Exec=java -jar game.jar + Icon=game.png + Terminal=false + Categories=Games; + """.trimIndent()) + + val gameIconFile = File(gameApp, "game.png") + gameIconFile.ensureParents().writeBytes(project.korge.getIconBytes(256)) + + project.copy { + it.from(fatJar) + it.into(gameApp) + it.rename { "game.jar" } + it.filePermissions { + it.user.execute = true + it.group.execute = true + it.other.execute = true + } + } + } + + fun createWin32Bundle(project: Project, fatJar: File) { + if (!LOCAL_WIN32_JRE.isDirectory) { + project.copy { + it.from(project.zipTree(cachedFile(JRE_WIN64))) + it.into(LOCAL_WIN32) + } + } + + val gameApp = File(project.buildDir, "platforms/jvm-win32").also { it.mkdirs() } + + val gameIcoFile = File(gameApp, "game.ico") + gameIcoFile.ensureParents().writeBytes(ICO2.encode(listOf(32, 256).map { + project.korge.getIconBytes(it).decodeImage() + })) + + ProcessBuilder( + WindowsToolchain.resourceHackerExe.absolutePath, + "-open", cachedFile(JRE_WIN64_LAUNCHER).absolutePath, + "-save", + File(gameApp, "game.exe").absolutePath, + "-action", + "addskip", + "-res", + gameIcoFile.absolutePath, + "-mask", + "ICONGROUP,MAINICON,", + ).start().waitFor() + + project.copy { + it.from(fatJar) + it.into(gameApp) + it.rename { "app.jar" } + it.filePermissions { + it.user.execute = true + it.group.execute = true + it.other.execute = true + } + } + + project.copy { + it.from(LOCAL_WIN32_JRE) + it.into(File(gameApp, "jre")) + } + } + + fun createMacosApp(project: Project, fatJar: File) { + if (!LOCAL_UNIVERSAL_JRE.isDirectory) { + project.copy { + it.from(project.tarTree(cachedFile(JRE_MACOS_UNIVERSAL))) + it.into(LOCAL_UNIVERSAL) + } + } + + val gameApp = File(project.buildDir, "platforms/jvm-macos/game.app/Contents").also { it.mkdirs() } + project.copy { + it.from(LOCAL_UNIVERSAL_JRE) + it.into(File(gameApp, "MacOS/jre")) + } + + val korge = project.korge + File(gameApp, "Resources/${korge.exeBaseName}.icns").ensureParents().writeBytes(IcnsBuilder.build(korge.getIconBytes())) + File(gameApp, "Info.plist").writeText(InfoPlistBuilder.build(korge)) + val exec = File(gameApp, "MacOS/${File(korge.exeBaseName).name}").ensureParents() + exec.writeBytes(cachedFile(JRE_MACOS_LAUNCHER).readBytes()) + exec.setExecutable(true) + project.copy { + it.from(fatJar) + it.into(File(gameApp, "MacOS")) + it.rename { "app.jar" } + it.filePermissions { + it.user.execute = true + it.group.execute = true + it.other.execute = true + } + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/Ios.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/Ios.kt new file mode 100644 index 0000000000..1d88a13cd5 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/Ios.kt @@ -0,0 +1,309 @@ +package korlibs.korge.gradle.targets.ios + +import korlibs.korge.gradle.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.desktop.* +import korlibs.korge.gradle.targets.jvm.* +import korlibs.korge.gradle.targets.native.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.tasks.* +import org.gradle.configurationcache.extensions.* +import org.jetbrains.kotlin.gradle.plugin.mpp.* +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.* +import org.jetbrains.kotlin.gradle.tasks.* +import java.io.* + +fun Project.configureNativeIos(projectType: ProjectType) { + configureNativeIosTvos(projectType, "ios") + configureNativeIosTvos(projectType, "tvos") + ensureSourceSetsConfigure("common", "ios", "tvos") + + val exKotlinSourceSetContainer = this.project.exKotlinSourceSetContainer + this.project.kotlin.apply { + sourceSets.apply { + for (target in listOf(iosArm64(), iosX64(), iosSimulatorArm64(), tvosArm64(), tvosX64(), tvosSimulatorArm64())) { + val native = createPairSourceSet(target.name, project = project) + when { + target.isIos -> native.dependsOn(exKotlinSourceSetContainer.ios) + target.isTvos -> native.dependsOn(exKotlinSourceSetContainer.tvos) + } + } + } + } +} + +val Project.xcframework by projectExtension() { + //XCFramework("${targetName}Universal") + XCFramework() +} + +fun Project.configureNativeIosTvos(projectType: ProjectType, targetName: String) { + val targetNameCapitalized = targetName.capitalized() + + val platformNativeFolderName = "platforms/native-$targetName" + val platformNativeFolder = File(buildDir, platformNativeFolderName) + + val prepareKotlinNativeBootstrapIosTvos = tasks.createThis("prepareKotlinNativeBootstrap${targetNameCapitalized}") { + doLast { + File(platformNativeFolder, "bootstrap.kt").apply { + parentFile.mkdirs() + writeText(IosProjectTools.genBootstrapKt(korge.realEntryPoint)) + } + } + } + + val iosTvosTargets = when (targetName) { + "ios" -> listOf(kotlin.iosX64(), kotlin.iosArm64(), kotlin.iosSimulatorArm64()) + "tvos" -> listOf(kotlin.tvosX64(), kotlin.tvosArm64(), kotlin.tvosSimulatorArm64()) + else -> TODO() + } + + kotlin.apply { + val xcf = XCFramework("$targetName") + //val xcf = project.xcframework + //val xcf = XCFramework() + + for (target in iosTvosTargets) { + target.configureKotlinNativeTarget(project) + //createCopyToExecutableTarget(target.name) + //for (target in listOf(iosX64())) { + target.also { target -> + //target.attributes.attribute(KotlinPlatformType.attribute, KotlinPlatformType.native) + target.binaries { + framework { + baseName = "GameMain" + xcf.add(this) + } + } + target.compilations["main"].also { compilation -> + //for (type in listOf(NativeBuildType.DEBUG, NativeBuildType.RELEASE)) { + // //getLinkTask(NativeOutputKind.FRAMEWORK, type).embedBitcode = Framework.BitcodeEmbeddingMode.DISABLE + //} + + //compilation.outputKind(NativeOutputKind.FRAMEWORK) + + compilation.defaultSourceSet.kotlin.srcDir(platformNativeFolder) + + if (projectType.isExecutable) { + afterEvaluate { + for (type in listOf(NativeBuildType.DEBUG, NativeBuildType.RELEASE)) { + compilation.getCompileTask(NativeOutputKind.FRAMEWORK, type, project).dependsOn(prepareKotlinNativeBootstrapIosTvos) + compilation.getLinkTask(NativeOutputKind.FRAMEWORK, type, project).dependsOn("prepareKotlinNative${targetNameCapitalized}Project") + } + } + } + } + } + } + } + + if (projectType.isExecutable) { + configureNativeIosTvosRun(targetName) + } +} + +fun Project.configureNativeIosTvosRun(targetName: String) { + val targetNameCapitalized = targetName.capitalized() + + val iosXcodegenExt = project.iosXcodegenExt + val iosSdkExt = project.iosSdkExt + + if (tasks.findByName("installXcodeGen") == null) { + tasks.createThis("installXcodeGen") { + onlyIf { !iosXcodegenExt.isInstalled() } + doLast { iosXcodegenExt.install() } + } + } + + val combinedResourcesFolder = File(buildDir, "combinedResources/resources") + val processedResourcesFolder = File(buildDir, "processedResources/${targetName}Arm64/main") + val copyIosTvosResources = tasks.createTyped("copy${targetNameCapitalized}Resources") { + val processResourcesTaskName = getProcessResourcesTaskName("${targetName}Arm64", "main") + dependsOn(processResourcesTaskName) + from(processedResourcesFolder) + into(combinedResourcesFolder) + } + + val prepareKotlinNativeIosTvosProject = tasks.createThis("prepareKotlinNative${targetNameCapitalized}Project") { + dependsOn("installXcodeGen", "prepareKotlinNativeBootstrap${targetNameCapitalized}", prepareKotlinNativeBootstrap, copyIosTvosResources) + doLast { + // project.yml requires these folders to be available, or it will fail + //File(rootDir, "src/commonMain/resources").mkdirs() + + val folder = File(buildDir, "platforms/$targetName") + IosProjectTools.prepareKotlinNativeIosProject(folder, targetName) + IosProjectTools.prepareKotlinNativeIosProjectIcons(folder) { korge.getIconBytes(it) } + IosProjectTools.prepareKotlinNativeIosProjectYml( + folder, + id = korge.id, + name = korge.name, + team = korge.iosDevelopmentTeam ?: korge.appleDevelopmentTeamId ?: iosSdkExt.appleGetDefaultDeveloperCertificateTeamId(), + combinedResourcesFolder = combinedResourcesFolder, + targetName = targetName + ) + + execLogger { + it.workingDir(folder) + it.commandLine(iosXcodegenExt.xcodeGenExe) + } + } + } + + tasks.createThis("${targetName}ShutdownSimulator") { + doFirst { + execLogger { it.commandLine("xcrun", "simctl", "shutdown", "booted") } + } + } + + val iphoneVersion = korge.preferredIphoneSimulatorVersion + + val iosCreateIphone = tasks.createThis("${targetName}CreateIphone") { + onlyIf { iosSdkExt.appleGetDevices().none { it.name == "iPhone $iphoneVersion" } } + doFirst { + val result = execOutput("xcrun", "simctl", "list") + val regex = Regex("com\\.apple\\.CoreSimulator\\.SimRuntime\\.iOS[\\w\\-]+") + val simRuntime = regex.find(result)?.value ?: error("Can't find SimRuntime. exec: xcrun simctl list") + logger.info("simRuntime: $simRuntime") + execLogger { it.commandLine("xcrun", "simctl", "create", "iPhone $iphoneVersion", "com.apple.CoreSimulator.SimDeviceType.iPhone-$iphoneVersion", simRuntime) } + } + } + + tasks.createThis("${targetName}BootSimulator") { + onlyIf { iosSdkExt.appleGetBootedDevice() == null } + dependsOn(iosCreateIphone) + doLast { + val device = iosSdkExt.appleGetBootDevice(iphoneVersion) + val udid = device.udid + logger.info("Booting udid=$udid") + if (logger.isInfoEnabled) { + for (device in iosSdkExt.appleGetDevices()) { + logger.info(" - $device") + } + } + execLogger { it.commandLine("xcrun", "simctl", "boot", udid) } + execLogger { it.commandLine("sh", "-c", "open `xcode-select -p`/Applications/Simulator.app/ --args -CurrentDeviceUDID $udid") } + } + } + + val installIosTvosDeploy = tasks.findByName("installIosDeploy") ?: tasks.createThis("installIosDeploy") { + onlyIf { !iosTvosDeployExt.isInstalled } + doFirst { + iosTvosDeployExt.installIfRequired() + } + } + + val updateIosTvosDeploy = tasks.findByName("updateIosDeploy") ?: tasks.createThis("updateIosDeploy") { + doFirst { + iosTvosDeployExt.update() + } + } + + for (debug in listOf(false, true)) { + val debugSuffix = if (debug) "Debug" else "Release" + for (simulator in listOf(false, true)) { + val simulatorSuffix = if (simulator) "Simulator" else "Device" + //val arch = if (simulator) "X64" else "Arm64" + //val arch2 = if (simulator) "x64" else "armv8" + val arch = when { + simulator -> if (isArm) "SimulatorArm64" else "X64" + else -> "Arm64" + } + val archNoSim = when { + simulator -> "X64" + else -> "Arm64" + } + val arch2 = when { + simulator -> if (isArm) "arm64" else "x86_64" + else -> "arm64" + } + val sdkName = if (simulator) "iphonesimulator" else "iphoneos" + tasks.createThis("${targetName}Build$simulatorSuffix$debugSuffix") { + //task.dependsOn(prepareKotlinNativeIosProject, "linkMain${debugSuffix}FrameworkIos$arch") + val linkTaskName = "link${debugSuffix}Framework${targetNameCapitalized}$arch" + dependsOn(prepareKotlinNativeIosTvosProject, linkTaskName) + val xcodeProjDir = buildDir["platforms/$targetName/app.xcodeproj"] + afterEvaluate { + val linkTask: KotlinNativeLink = tasks.findByName(linkTaskName) as KotlinNativeLink + inputs.dir(linkTask.outputFile) + outputs.file(xcodeProjDir["build/Build/Products/$debugSuffix-$sdkName/${korge.name}.app/${korge.name}"]) + } + //afterEvaluate { + //} + workingDir(xcodeProjDir) + doFirst { + //commandLine("xcrun", "xcodebuild", "-allowProvisioningUpdates", "-scheme", "app-$archNoSim-$debugSuffix", "-project", ".", "-configuration", debugSuffix, "-derivedDataPath", "build", "-arch", arch2, "-sdk", iosSdkExt.appleFindSdk(sdkName)) + commandLine("xcrun", "xcodebuild", "-allowProvisioningUpdates", "-scheme", "app-$arch-$debugSuffix", "-project", ".", "-configuration", debugSuffix, "-derivedDataPath", "build", "-arch", arch2, "-sdk", iosSdkExt.appleFindSdk(sdkName)) + println("COMMAND: ${commandLine.joinToString(" ")}") + } + } + } + + + val installIosSimulator = tasks.createThis("install${targetNameCapitalized}Simulator$debugSuffix") { + val buildTaskName = "${targetName}BuildSimulator$debugSuffix" + group = GROUP_KORGE_INSTALL + + dependsOn(buildTaskName, "${targetName}BootSimulator") + doLast { + val appFolder = tasks.getByName(buildTaskName).outputs.files.first().parentFile + val device = iosSdkExt.appleGetInstallDevice(iphoneVersion) + execLogger { it.commandLine("xcrun", "simctl", "install", device.udid, appFolder.absolutePath) } + } + } + + // packageIosSimulatorDebug + // packageIosDeviceDebug + // packageIosSimulatorRelease + // packageIosDeviceRelease + for (Kind in listOf("Simulator", "Device")) { + val packageIos = tasks.createThis("package${targetNameCapitalized}$Kind$debugSuffix") { + group = GROUP_KORGE_PACKAGE + dependsOn("${targetName}Build$Kind$debugSuffix") + } + } + + val installIosTvosDevice = tasks.createThis("install${targetNameCapitalized}Device$debugSuffix") { + group = GROUP_KORGE_INSTALL + val buildTaskName = "${targetName}BuildDevice$debugSuffix" + dependsOn(installIosTvosDeploy, buildTaskName) + doLast { + val appFolder = tasks.getByName(buildTaskName).outputs.files.first().parentFile + iosTvosDeployExt.install(appFolder.absolutePath) + } + } + + val runIosDevice = tasks.createTyped("run${targetNameCapitalized}Device$debugSuffix") { + group = GROUP_KORGE_RUN + val buildTaskName = "${targetName}BuildDevice$debugSuffix" + dependsOn(installIosTvosDeploy, buildTaskName) + doFirst { + val appFolder = tasks.getByName(buildTaskName).outputs.files.first().parentFile + iosTvosDeployExt.installAndRun(appFolder.absolutePath) + } + } + + val runIosSimulator = tasks.createTyped("run${targetNameCapitalized}Simulator$debugSuffix") { + group = GROUP_KORGE_RUN + dependsOn(installIosSimulator) + doFirst { + val device = iosSdkExt.appleGetInstallDevice(iphoneVersion) + // xcrun simctl launch --console 7F49203A-1F16-4DEE-B9A2-7A1BB153DF70 com.sample.demo.app-X64-Debug + //logger.info(params.joinToString(" ")) + val arch = if (isArm) "SimulatorArm64" else "X64" + execLogger { it.commandLine("xcrun", "simctl", "launch", "--console", device.udid, "${korge.id}.app-$arch-$debugSuffix") } + } + } + + tasks.createTyped("run${targetNameCapitalized}$debugSuffix") { + dependsOn(runIosDevice) + } + } + + tasks.createThis("${targetName}EraseAllSimulators") { + doLast { execLogger { it.commandLine("osascript", "-e", "tell application \"iOS Simulator\" to quit") } } + doLast { execLogger { it.commandLine("osascript", "-e", "tell application \"Simulator\" to quit") } } + doLast { execLogger { it.commandLine("xcrun", "simctl", "erase", "all") } } + } + +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosDeploy.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosDeploy.kt new file mode 100644 index 0000000000..b2411d1daa --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosDeploy.kt @@ -0,0 +1,75 @@ +package korlibs.korge.gradle.targets.ios + +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import java.io.* + +val Project.iosTvosDeployExt by projectExtension { + IosDeploy(this) +} + +val korgeCacheDir get() = File(System.getProperty("user.home"), ".korge").apply { mkdirs() } + +class IosDeploy(val project: Project) { + val iosDeployVersion = "1.12.2" + //val iosDeployRepo = "https://github.com/korlibs/ios-deploy.git" + val iosDeployRepo = "https://github.com/ios-control/ios-deploy.git" + val iosDeployDir = File(korgeCacheDir, "ios-deploy-$iosDeployVersion") + val iosDeployCmd = File(iosDeployDir, "build/Release/ios-deploy") + + val isInstalled get() = iosDeployCmd.exists() + + // https://github.com/ios-control/ios-deploy/issues/588 + // no ios-deploy required anymore? + // xcrun devicectl list devices -j /tmp/devices.json + // xcrun devicectl device install app --device 00008110-001XXXXXXXXXX ./korge-sandbox/build/platforms/ios/app.xcodeproj/build/Build/Products/Debug-iphoneos/unnamed.app + // crun devicectl device process launch --device 00008110-001XXXXXXXXXX file:///private/var/containers/Bundle/Application/1604D2D5-35F3-4E43-8B47-1DEF5D778480/nilo.app + + fun install(localAppPath: String) { + command("--bundle", localAppPath) + } + + fun installAndRun(localAppPath: String) { + command("--noninteractive", "-d", "--bundle", localAppPath) + } + + fun command(vararg cmds: String) { + project.execLogger { + it.commandLine(iosDeployCmd, *cmds) + it.standardInput = System.`in` + } + } + + fun update() { + installIfRequired() + project.execLogger { + it.workingDir = iosDeployDir + it.commandLine("git", "pull") + } + build() + } + + fun clone() { + iosDeployDir.mkdirs() + project.execLogger { + it.workingDir(iosDeployDir.absolutePath) + it.commandLine("git", "clone", iosDeployRepo, iosDeployDir.absolutePath) + } + project.execLogger { + it.workingDir(iosDeployDir.absolutePath) + it.commandLine("git", "checkout", iosDeployVersion) + } + } + + fun installIfRequired() { + if (!File(iosDeployDir, ".git").exists()) clone() + if (!isInstalled) build() + } + + fun build() { + project.execLogger { + it.commandLine("xcodebuild", "-target", "ios-deploy") + it.workingDir = iosDeployDir + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosProjectTools.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosProjectTools.kt new file mode 100644 index 0000000000..b3c2fc546c --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosProjectTools.kt @@ -0,0 +1,285 @@ +package korlibs.korge.gradle.targets.ios + +import korlibs.korge.gradle.util.* +import org.gradle.configurationcache.extensions.* +import java.io.* + +object IosProjectTools { + fun genBootstrapKt(entrypoint: String): String = """ + import $entrypoint + + object NewAppDelegate : korlibs.render.KorgwBaseNewAppDelegate() { + override fun applicationDidFinishLaunching(app: platform.UIKit.UIApplication) { applicationDidFinishLaunching(app) { ${entrypoint}() } } + } + """.trimIndent() + + fun genMainObjC(): String = """ + #import + #import + + @interface AppDelegate : UIResponder + @property (strong, nonatomic) UIWindow *window; + @end + + int main(int argc, char * argv[]) { + @autoreleasepool { return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); } + } + + @implementation AppDelegate + - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + [[GameMainNewAppDelegate getNewAppDelegate] applicationDidFinishLaunchingApp: application]; + return YES; + } + - (void)applicationWillResignActive:(UIApplication *)application { + [[GameMainNewAppDelegate getNewAppDelegate] applicationWillResignActiveApp: application]; + } + - (void)applicationDidEnterBackground:(UIApplication *)application { + [[GameMainNewAppDelegate getNewAppDelegate] applicationDidEnterBackgroundApp: application]; + } + - (void)applicationWillEnterForeground:(UIApplication *)application { + [[GameMainNewAppDelegate getNewAppDelegate] applicationWillEnterForegroundApp: application]; + } + - (void)applicationDidBecomeActive:(UIApplication *)application { + [[GameMainNewAppDelegate getNewAppDelegate] applicationDidBecomeActiveApp: application]; + } + - (void)applicationWillTerminate:(UIApplication *)application { + [[GameMainNewAppDelegate getNewAppDelegate] applicationWillTerminateApp: application]; + } + @end + """.trimIndent() + + fun genLaunchScreenStoryboard(targetName: String): String { + val documentType = when (targetName) { + "ios" -> "com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" + "tvos" -> "com.apple.InterfaceBuilder.AppleTV.Storyboard" + else -> TODO() + } + val targetRuntime = when (targetName) { + "ios" -> "iOS.CocoaTouch" + "tvos" -> "AppleTV" + else -> TODO() + } + val (sizeWidth, sizeHeight) = when (targetName) { + "ios" -> 375 to 667 + "tvos" -> 1920 to 1000 + else -> TODO() + } + + return """ + + + + + + + + + + + + + + + + + + + + + + + + + + """.trimIndent() + } + + fun prepareKotlinNativeIosProject(folder: File, targetName: String) { + folder["app/main.m"].ensureParents().writeText(genMainObjC()) + folder["app/Base.lproj/LaunchScreen.storyboard"].ensureParents().writeText(genLaunchScreenStoryboard(targetName)) + folder["app/Assets.xcassets/Contents.json"].ensureParents().writeText(""" + { + "info" : { + "version" : 1, + "author" : "xcode" + } + } + """.trimIndent()) + folder["app/Info.plist"].ensureParents().writeText(Indenter { + line("") + line("") + line("") + line("") + indent { + line("CFBundleDevelopmentRegion") + line("$(DEVELOPMENT_LANGUAGE)") + line("CFBundleExecutable") + line("$(EXECUTABLE_NAME)") + line("CFBundleIdentifier") + line("$(PRODUCT_BUNDLE_IDENTIFIER)") + line("CFBundleInfoDictionaryVersion") + line("6.0") + line("CFBundleName") + line("$(PRODUCT_NAME)") + line("CFBundlePackageType") + line("APPL") + line("CFBundleShortVersionString") + line("1.0") + line("CFBundleVersion") + line("1") + line("LSRequiresIPhoneOS") + line("") + line("UILaunchStoryboardName") + line("LaunchScreen") + //line("UIMainStoryboardFile") + //line("Main") + line("UIRequiredDeviceCapabilities") + line("") + indent { + line("armv7") + } + line("") + line("UISupportedInterfaceOrientations") + line("") + indent { + line("UIInterfaceOrientationPortrait") + line("UIInterfaceOrientationLandscapeLeft") + line("UIInterfaceOrientationLandscapeRight") + } + line("") + line("UISupportedInterfaceOrientations~ipad") + line("") + indent { + line("UIInterfaceOrientationPortrait") + line("UIInterfaceOrientationPortraitUpsideDown") + line("UIInterfaceOrientationLandscapeLeft") + line("UIInterfaceOrientationLandscapeRight") + } + line("") + } + line("") + line("") + }) + } + + fun prepareKotlinNativeIosProjectIcons(folder: File, getIconBytes: (size: Int) -> ByteArray) { + data class IconConfig(val idiom: String, val size: Number, val scale: Int) { + val sizeStr = "${size}x$size" + val scaleStr = "${scale}x" + val realSize = (size.toDouble() * scale).toInt() + val fileName = "icon$realSize.png" + } + val icons = listOf( + IconConfig("iphone", 20, 2), + IconConfig("iphone", 20, 3), + IconConfig("iphone", 29, 2), + IconConfig("iphone", 20, 3), + IconConfig("iphone", 40, 2), + IconConfig("iphone", 40, 3), + IconConfig("iphone", 60, 2), + IconConfig("iphone", 60, 3), + IconConfig("ipad", 20, 1), + IconConfig("ipad", 20, 2), + IconConfig("ipad", 29, 1), + IconConfig("ipad", 29, 2), + IconConfig("ipad", 40, 1), + IconConfig("ipad", 40, 2), + IconConfig("ipad", 76, 1), + IconConfig("ipad", 76, 2), + IconConfig("ipad", 83.5, 2), + IconConfig("ios-marketing", 1024, 1) + ) + + for (icon in icons.distinctBy { it.realSize }) { + folder["app/Assets.xcassets/AppIcon.appiconset/${icon.fileName}"].ensureParents().writeBytes(getIconBytes(icon.realSize)) + } + + folder["app/Assets.xcassets/AppIcon.appiconset/Contents.json"].ensureParents().writeText( + Indenter { + line("{") + indent { + line("\"images\" : [") + indent { + for ((index, config) in icons.withIndex()) { + val isLast = (index == icons.lastIndex) + val tail = if (isLast) "" else "," + line("{ \"idiom\" : ${config.idiom.quoted}, \"size\" : ${config.sizeStr.quoted}, \"scale\" : ${config.scaleStr.quoted}, \"filename\" : ${config.fileName.quoted} }$tail") + } + } + line("],") + line("\"info\" : { \"version\": 1, \"author\": \"xcode\" }") + } + line("}") + } + ) + } + + fun prepareKotlinNativeIosProjectYml( + folder: File, + id: String, + name: String, + team: String?, + combinedResourcesFolder: File, + targetName: String + ) { + val targetNameCapitalized = targetName.capitalized() + + folder["project.yml"].ensureParents().writeText(Indenter { + line("name: app") + line("options:") + indent { + line("bundleIdPrefix: $id") + line("minimumXcodeGenVersion: 2.0.0") + } + line("settings:") + indent { + line("PRODUCT_NAME: $name") + line("ENABLE_BITCODE: NO") + if (team != null) { + line("DEVELOPMENT_TEAM: $team") + } + } + line("targets:") + indent { + for (debug in listOf(false, true)) { + val debugSuffix = if (debug) "Debug" else "Release" + for (arch in listOf("X64", "SimulatorArm64", "Arm64")) { + line("app-$arch-$debugSuffix:") + indent { + line("platform: ${if (targetName == "ios") "iOS" else "tvOS"}") + line("type: application") + line("deploymentTarget: \"15.0\"") + line("sources:") + indent { + line("- app") + //for (path in listOf("../../../src/commonMain/resources", "../../../build/genMainResources")) { + for (path in listOf(combinedResourcesFolder.relativeTo(folder))) { + line("- path: $path") + indent { + line("name: assets") + line("optional: true") + line("buildPhase:") + indent { + line("copyFiles:") + indent { + line("destination: resources") + line("subpath: include/app") + } + } + line("type: folder") + } + } + } + if (team != null) { + line("settings:") + line(" DEVELOPMENT_TEAM: $team") + } + line("dependencies:") + line(" - framework: ../../bin/${targetName}$arch/${debugSuffix.lowercase()}Framework/GameMain.framework") + } + } + } + } + }.replace("\t", " ")) + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosSdk.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosSdk.kt new file mode 100644 index 0000000000..ad43391519 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosSdk.kt @@ -0,0 +1,117 @@ +package korlibs.korge.gradle.targets.ios + +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import java.io.* +import java.security.cert.* +import java.util.* +import javax.security.auth.x500.* + +val Project.iosSdkExt by projectExtension { + IosSdk(this) +} + +class IosSdk(val project: Project) { + fun appleGetBootDevice(iphoneVersion: Int): IosDevice { + val devices = appleGetDevices() + return devices.firstOrNull { it.name == "iPhone $iphoneVersion" && it.isAvailable } + ?: devices.firstOrNull { it.name.contains("iPhone") && it.isAvailable } + ?: run { + val errorMessage = "Can't find suitable available iPhone $iphoneVersion device" + project.logger.info(errorMessage) + for (device in devices) project.logger.info("- $device") + error(errorMessage) + } + } + + fun appleGetInstallDevice(iphoneVersion: Int): IosDevice { + val devices = appleGetDevices() + return devices.firstOrNull { it.name == "iPhone $iphoneVersion" && it.booted } + ?: devices.firstOrNull { it.name.contains("iPhone") && it.booted } + ?: error("Can't find suitable booted iPhone $iphoneVersion device") + } + + fun appleGetBootedDevice(os: String = "iOS"): IosDevice? = appleGetDevices(os).firstOrNull { it.booted } + fun appleFindSdk(name: String): String = Regex("(${name}.*)").find(project.execOutput("xcrun", "xcodebuild", "-showsdks"))?.groupValues?.get(0) ?: error("Can't find sdk starting with $name") + fun appleFindIphoneSimSdk(): String = appleFindSdk("iphonesimulator") + fun appleFindIphoneOsSdk(): String = appleFindSdk("iphoneos") + + data class IosDevice(val booted: Boolean, val isAvailable: Boolean, val name: String, val udid: String) + + // https://gist.github.com/luckman212/ec52e9291f27bc39c2eecee07e7a9aa7 + fun appleGetDefaultDeveloperCertificateTeamId(): String? { + @Throws(IOException::class) + fun execCmd(vararg cmd: String?): String { + return Runtime.getRuntime().exec(cmd).inputStream.reader().readText() + } + + fun certFromFilter(filter: String?): String? { + return execCmd(*buildList { + add("security") + add("find-certificate") + if (filter != null) { + add("-c") + add("Apple Development:") + } + add("-p") + }.toTypedArray()) + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .lines() + .joinToString("").trim().takeIf { it.isNotEmpty() } + } + + val certB64 = certFromFilter("Apple Development:") ?: certFromFilter(null) + + val cert = CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(Base64.getDecoder().decode(certB64))) as X509Certificate + val subjectStr = cert.subjectX500Principal.getName(X500Principal.RFC2253) + val teamId = Regex("OU=([^,]+)").find(subjectStr)?.groups?.get(1)?.value + + //println("CERT=$cert") + //println("subjectStr=$subjectStr") + //println("teamId=$teamId") + + return teamId + } + + fun appleGetDevices(os: String = "iOS"): List { + val res = Json.parse(project.execOutput("xcrun", "simctl", "list", "-j", "devices")).dyn + val devices = res["devices"] + val oses = devices.keys.map { it.str } + val iosOses = oses.filter { it.contains(os) } + return iosOses.map { devices[it].list }.flatten().map { + //println(it) + IosDevice(it["state"].str == "Booted", it["isAvailable"].bool, it["name"].str, it["udid"].str).also { + //println(it) + } + } + } + + //tasks.createThis("iosLaunchSimulator") { + // dependsOn("iosInstallSimulator") + // doLast { + // val udid = appleGetDevices().firstOrNull { it.name == "iPhone 7" }?.udid ?: error("Can't find iPhone 7 device") + // execLogger { commandLine("xcrun", "simctl", "launch", "-w", udid, korge.id) } + // + // } + //} + + + //task iosLaunchSimulator(type: Exec, dependsOn: [iosInstallSimulator]) { + // workingDir file("client-mpp-ios.xcodeproj") + // executable "sh" + // args "-c", "xcrun simctl launch booted io.ktor.samples.mpp.client-mpp-ios" + //} + + // https://www.objc.io/issues/17-security/inside-code-signing/ + // security find-identity -v -p codesigning + // codesign -s 'iPhone Developer: Thomas Kollbach (7TPNXN7G6K)' Example.app + // codesign -f -s 'iPhone Developer: Thomas Kollbach (7TPNXN7G6K)' Example.app + + //osascript -e 'tell application "iOS Simulator" to quit' + //osascript -e 'tell application "Simulator" to quit' + //xcrun simctl erase all + + // xcrun lipo + +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosXcodegen.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosXcodegen.kt new file mode 100644 index 0000000000..0d288b9f8b --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/ios/IosXcodegen.kt @@ -0,0 +1,45 @@ +package korlibs.korge.gradle.targets.ios + +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import java.io.* + +val Project.iosXcodegenExt by projectExtension { + IosXcodegen(this) +} + +class IosXcodegen(val project: Project) { + val xcodeGenGitTag = "2.42.0" + val korlibsFolder = File(System.getProperty("user.home") + "/.korge").apply { mkdirs() } + val xcodeGenFolder = File(korlibsFolder, "XcodeGen-$xcodeGenGitTag") + val xcodeGenLocalExecutable = File("/usr/local/bin/xcodegen") + val xcodeGenExecutable = FileList( + File(xcodeGenFolder, ".build/release/xcodegen"), + File(xcodeGenFolder, ".build/apple/Products/Release/xcodegen"), + ) + val xcodeGenExe: File + get() = xcodeGenExecutable.takeIfExists() ?: xcodeGenLocalExecutable.takeIfExists() ?: error("Can't find xcodegen") + + fun isInstalled(): Boolean = xcodeGenLocalExecutable.exists() || xcodeGenExecutable.exists() + fun install() { + if (!File(xcodeGenFolder, ".git").isDirectory) { + project.execLogger { + //it.commandLine("git", "clone", "--depth", "1", "--branch", xcodeGenGitTag, "https://github.com/yonaskolb/XcodeGen.git") + it.commandLine("git", "clone", "https://github.com/yonaskolb/XcodeGen.git", xcodeGenFolder) + it.workingDir(korlibsFolder) + } + } + project.execLogger { + it.commandLine("git", "pull") + it.workingDir(xcodeGenFolder) + } + project.execLogger { + it.commandLine("git", "checkout", xcodeGenGitTag) + it.workingDir(xcodeGenFolder) + } + project.execLogger { + it.commandLine("make", "build") + it.workingDir(xcodeGenFolder) + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/ClosureCompiler.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/ClosureCompiler.kt new file mode 100644 index 0000000000..6de0a12749 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/ClosureCompiler.kt @@ -0,0 +1,26 @@ +package korlibs.korge.gradle.targets.js + +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.tasks.Copy +import org.jetbrains.kotlin.gradle.targets.js.webpack.* +import java.io.* + +fun Project.configureWebpack() { + val wwwFolder = File(buildDir, "www") + + val browserReleaseWebpack = tasks.createThis("browserReleaseWebpack") { + val jsBrowserProductionWebpack: KotlinWebpack = tasks.getByName("jsBrowserProductionWebpack") as KotlinWebpack + dependsOn(jsBrowserProductionWebpack) + //val jsFile = browserReleaseEsbuild.outputs.files.first() + val jsFile = jsBrowserProductionWebpack.mainOutputFile.get().asFile + val mapFile = File(jsFile.parentFile, jsFile.name + ".map") + + from(project.tasks.getByName("jsProcessResources").outputs.files) + from(jsFile) + from(mapFile) + registerModulesResources(project) + into(wwwFolder) + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/Esbuild.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/Esbuild.kt new file mode 100644 index 0000000000..a73b6c0caf --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/Esbuild.kt @@ -0,0 +1,135 @@ +package korlibs.korge.gradle.targets.js + +import korlibs.korge.gradle.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.file.* +import org.gradle.api.tasks.* +import org.jetbrains.kotlin.gradle.targets.js.ir.* +import java.io.* + +fun Project.configureEsbuild() { + try { + configureErrorableEsbuild() + } catch (e: Throwable) { + e.printStackTrace() + } +} + +fun Project.configureErrorableEsbuild() { + val userGradleFolder = File(System.getProperty("user.home"), ".gradle") + + val wwwFolder = File(buildDir, "www") + + val esbuildFolder = File(if (userGradleFolder.isDirectory) userGradleFolder else rootProject.buildDir, "esbuild") + val isWindows = org.apache.tools.ant.taskdefs.condition.Os.isFamily(org.apache.tools.ant.taskdefs.condition.Os.FAMILY_WINDOWS) + val esbuildCommand = File(esbuildFolder, if (isWindows) "esbuild.cmd" else "bin/esbuild") + val esbuildCmd = if (isWindows) listOf("cmd.exe", "/c", esbuildCommand) else listOf(esbuildCommand) + + val npmInstallEsbuildTaskName = "npmInstallEsbuild" + + val env by lazy { org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin.apply(project.rootProject).requireConfigured() } + val ENV_PATH by lazy { + val NODE_PATH = File(env.nodeExecutable).parent + val PATH_SEPARATOR = File.pathSeparator + val OLD_PATH = System.getenv("PATH") + "$NODE_PATH$PATH_SEPARATOR$OLD_PATH" + } + + val npmInstallEsbuild = rootProject.tasks.findByName(npmInstallEsbuildTaskName) ?: rootProject.tasks.createThis(npmInstallEsbuildTaskName) { + dependsOn("kotlinNodeJsSetup") + onlyIf { !esbuildCommand.exists() } + + val esbuildVersion = korge.esbuildVersion + doFirst { + //val nodeDir = env.nodeBinDir + val nodeDir = env.dir + val file1: File = File(env.nodeExecutable) + val file2: File? = File(nodeDir, "lib/node_modules/npm/bin/npm-cli.js").takeIf { it.exists() } + val file3: File? = File(nodeDir, "node_modules/npm/bin/npm-cli.js").takeIf { it.exists() } + val npmCmd = arrayOf( + file1, + file2 ?: file2 ?: file3 + ?: error("Can't find npm-cli.js in $nodeDir standard folders $file1, $file2, $file3") + ) + + environment("PATH", ENV_PATH) + commandLine(*npmCmd, "-g", "install", "esbuild@$esbuildVersion", "--prefix", esbuildFolder, "--scripts-prepend-node-path", "true") + } + } + + val browserEsbuildResources = tasks.createThis("browserEsbuildResources") { + val korgeProcessResourcesTaskName = getKorgeProcessResourcesTaskName("js", "main") + dependsOn(korgeProcessResourcesTaskName) + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(project.tasks.getByName("jsProcessResources").outputs.files) + //from(kotlin.targets.getByName("js").compilations.main.defaultSourceSet.resources) + registerModulesResources(project) + into(wwwFolder) + } + + val browserPrepareEsbuildPrepare = tasks.createThis("browserPrepareEsbuildPrepare") { + dependsOn(browserEsbuildResources) + dependsOn(npmInstallEsbuild) + } + + val browserPrepareEsbuildDebug = tasks.createThis("browserPrepareEsbuildDebug") { + dependsOn("compileDevelopmentExecutableKotlinJs") + dependsOn(browserPrepareEsbuildPrepare) + } + + val browserPrepareEsbuildRelease = tasks.createThis("browserPrepareEsbuildRelease") { + dependsOn("compileProductionExecutableKotlinJs") + dependsOn(browserPrepareEsbuildPrepare) + } + + for (debug in listOf(false, true)) { + val debugPrefix = if (debug) "Debug" else "Release" + val productionInfix = if (debug) "Development" else "Production" + val browserPrepareEsbuild = when { + debug -> browserPrepareEsbuildDebug + else -> browserPrepareEsbuildRelease + } + + // browserDebugEsbuild + // browserReleaseEsbuild + tasks.createThis("browser${debugPrefix}Esbuild") { + group = "kotlin browser" + val compileExecutableKotlinJs = tasks.getByName("compile${productionInfix}ExecutableKotlinJs") as KotlinJsIrLink + dependsOn(browserPrepareEsbuild) + dependsOn(compileExecutableKotlinJs) + + val jsBasePath = compileExecutableKotlinJs.destinationDirectory.asFile.get().absolutePath + "/" + compileExecutableKotlinJs.compilerOptions.moduleName.get() + val jsPath = "$jsBasePath.js" // Normal JS + val mjsPath = "$jsBasePath.mjs" // ES2015 + val finalJsPath = mjsPath + //val finalJsPath = jsPath + + val output = File(wwwFolder, "${project.name}.js") + //println("jsPath=$jsPath") + //println("jsPath.parentFile=${jsPath.parentFile}") + //println("outputs=${compileExecutableKotlinJs.outputs.files.toList()}") + inputs.files(compileExecutableKotlinJs.outputs.files) + outputs.file(output) + environment("PATH", ENV_PATH) + doFirst { + commandLine(buildList { + addAll(esbuildCmd) + //add("--watch",) + add("--bundle") + if (!debug) { + add("--minify") + add("--sourcemap=external") + } + add(finalJsPath) + add("--outfile=$output") + // @TODO: Close this command on CTRL+C + //if (run) add("--servedir=$wwwFolder") + }) + } + + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/JavaScript.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/JavaScript.kt new file mode 100644 index 0000000000..bfe15b72bb --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/JavaScript.kt @@ -0,0 +1,184 @@ +package korlibs.korge.gradle.targets.js + +import korlibs.korge.gradle.* +import korlibs.korge.gradle.gkotlin +import korlibs.korge.gradle.kotlin +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.windows.* +import korlibs.korge.gradle.util.* +import korlibs.* +import korlibs.korge.gradle.targets.jvm.* +import org.gradle.api.* +import org.gradle.api.file.* +import org.gradle.api.tasks.* +import org.jetbrains.kotlin.gradle.plugin.* +import org.jetbrains.kotlin.gradle.targets.js.dsl.* +import java.io.* + +private object JavaScriptClass + +fun Project.configureJavaScript(projectType: ProjectType) { + if (gkotlin.targets.findByName("js") != null) return + + rootProject.plugins.withType(org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin::class.java).allThis { + try { + rootProject.the().nodeVersion = + BuildVersions.NODE_JS + } catch (e: Throwable) { + // Ignore failed because already configured + } + } + + gkotlin.apply { + js(KotlinJsCompilerType.IR) { + browser { + binaries.executable() + } + + this.attributes.attribute(KotlinPlatformType.attribute, KotlinPlatformType.js) + + compilations.allThis { + kotlinOptions.apply { + sourceMap = korge.sourceMaps + //metaInfo = true + //moduleKind = "umd" + suppressWarnings = korge.supressWarnings + } + } + configureJsTargetOnce() + configureJSTestsOnce() + } + + sourceSets.maybeCreate("jsTest").apply { + dependencies { + implementation("org.jetbrains.kotlin:kotlin-test-js") + } + } + } + + // https://youtrack.jetbrains.com/issue/KT-58187/KJS-IR-Huge-performance-bottleneck-while-generating-sourceMaps-getCannonicalFile#focus=Comments-27-7301819.0-0 + tasks.withType { + kotlinOptions.sourceMap = korge.sourceMaps + } + + val generatedIndexHtmlDir = File(project.buildDir, "processedResources-www") + + afterEvaluate { + val jsCreateIndexHtml = project.tasks.createThis("jsCreateIndexHtml").also { task -> + val jsMainCompilation = kotlin.js().compilations["main"]!! + val resourcesFolders: List = jsMainCompilation.allKotlinSourceSets + .flatMap { it.resources.srcDirs } + listOf( + File(rootProject.rootDir, "_template"), + File(rootProject.rootDir, "buildSrc/src/main/resources"), + ) + task.resourcesFolders = resourcesFolders + task.targetDir = generatedIndexHtmlDir + } + (project.tasks.getByName("jsProcessResources") as Copy).apply { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + dependsOn(jsCreateIndexHtml) + from(generatedIndexHtmlDir) { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } + //println(this.outputs.files.toList()) + + } + } + + configureEsbuild() + configureWebpackFixes() + configureDenoTest() + if (projectType.isExecutable) { + configureDenoRun() + configureJavascriptRun() + } + configureWebpack() + + ensureSourceSetsConfigure("common", "js") +} + +fun KotlinJsTargetDsl.configureJsTargetOnce() { + this.compilerOptions { + target.set("es2015") + } +} + +fun KotlinJsTargetDsl.configureJSTestsOnce() { + browser { + //testTask { useKarma { useChromeHeadless() } } + testRuns.getByName(KotlinTargetWithTests.DEFAULT_TEST_RUN_NAME).executionTask.configure { + it.useKarma { + useChromeHeadless() + File(project.rootProject.rootDir, "karma.config.d").takeIfExists()?.let { + useConfigDirectory(it) + } + } + } + } + + // Kotlin 1.8.10: + // compileSync: task ':korio:jsTestTestDevelopmentExecutableCompileSync' : [/Users/soywiz/projects/korge/build/js/packages/korge-root-korio-test/kotlin] + // Kotlin 1.8.20-RC: + // compileSync: task ':korio:jsTestTestDevelopmentExecutableCompileSync' : [/Users/soywiz/projects/korge/build/js/packages/korge-root-korio-test/kotlin] + //for (kind in listOf("Development", "Production")) { + // val compileSync = project.tasks.findByName("jsTestTest${kind}ExecutableCompileSync") as Copy + // println("compileSync: $compileSync : ${compileSync.outputs.files.files}") + //} +} + +abstract class JsCreateIndexTask : DefaultTask() { + @get:InputFiles lateinit var resourcesFolders: List + //@get:OutputDirectory lateinit var targetDir: File + @Internal lateinit var targetDir: File + private val projectName: String = project.name + private val korgeTitle: String? = project.korge.title + private val korgeName: String? = project.korge.name + + private val iconProvider: KorgeIconProvider = KorgeIconProvider(project) + + @TaskAction + fun run() { + targetDir.mkdirs() + logger.info("jsCreateIndexHtml.targetDir: $targetDir") + //val jsFile = File(jsMainCompilation.kotlinOptions.outputFile ?: "dummy.js").name + // @TODO: How to get the actual .js file generated/served? + val jsFile = File("${projectName}.js").name + //println("jsFile: $jsFile") + //println("resourcesFolders: $resourcesFolders") + fun readTextFile(name: String): String { + for (folder in resourcesFolders) { + val file = File(folder, name)?.takeIf { it.exists() } ?: continue + return file.readText() + } + return JavaScriptClass::class.java.classLoader.getResourceAsStream(name)?.readBytes()?.toString(Charsets.UTF_8) + ?: error("We cannot find suitable '$name'") + } + + val indexTemplateHtml = readTextFile("index.v2.template.html") + val customCss = readTextFile("custom-styles.template.css") + val customHtmlHead = readTextFile("custom-html-head.template.html") + val customHtmlBody = readTextFile("custom-html-body.template.html") + + //println(File(targetDir, "index.html")) + + try { + File(targetDir, "favicon.ico").writeBytes(ICO2.encode(listOf(16, 32).map { + iconProvider.getIconBytes(it).decodeImage() + })) + } catch (e: Throwable) { + e.printStackTrace() + } + + File(targetDir, "index.html").writeText( + groovy.text.SimpleTemplateEngine().createTemplate(indexTemplateHtml).make( + mapOf( + "OUTPUT" to jsFile, + "TITLE" to (korgeTitle ?: korgeName ?: "KorGE"), + "CUSTOM_CSS" to customCss, + "CUSTOM_HTML_HEAD" to customHtmlHead, + "CUSTOM_HTML_BODY" to customHtmlBody + ) + ).toString() + ) + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/JavaScriptDeno.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/JavaScriptDeno.kt new file mode 100644 index 0000000000..4b22d02025 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/JavaScriptDeno.kt @@ -0,0 +1,197 @@ +package korlibs.korge.gradle.targets.js + +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.internal.tasks.testing.* +import org.gradle.api.tasks.* +import org.gradle.api.tasks.testing.* +import java.io.* + +fun Project.configureDenoTest() { + afterEvaluate { + if (tasks.findByName("compileTestDevelopmentExecutableKotlinJs") == null) return@afterEvaluate + + val jsDenoTest = project.tasks.createThis("jsDenoTest") { + } + } +} + + +fun Project.configureDenoRun() { + afterEvaluate { + if (tasks.findByName("compileDevelopmentExecutableKotlinJs") == null) return@afterEvaluate + + val baseRunFileNameBase = project.fullPathName().trim(':').replace(':', '-') + val baseRunFileName = "$baseRunFileNameBase.mjs" + val runFile = File(rootProject.rootDir, "build/js/packages/$baseRunFileNameBase/kotlin/$baseRunFileName") + + val runDeno = project.tasks.createThis("runDeno") { + group = GROUP_KORGE_RUN + dependsOn("compileDevelopmentExecutableKotlinJs") + commandLine("deno", "run", "--unstable-ffi", "--unstable-webgpu", "-A", runFile) + workingDir(runFile.parentFile.absolutePath) + } + + val packageDeno = project.tasks.createThis("packageDeno") { + group = GROUP_KORGE_PACKAGE + dependsOn("compileDevelopmentExecutableKotlinJs") + commandLine("deno", "compile", "--unstable-ffi", "--unstable-webgpu", "-A", runFile) + workingDir(runFile.parentFile.absolutePath) + } + } +} + +open class DenoTestTask : AbstractTestTask() { +//open class DenoTestTask : KotlinTest() { + + //var isDryRun by org.jetbrains.kotlin.gradle.utils.property { false } + + init { + this.group = "verification" + this.dependsOn("compileTestDevelopmentExecutableKotlinJs") + } + + //@Option(option = "tests", description = "Specify tests to execute as a filter") + //@Input + //var tests: String = "" + + init { + this.reports { + it.junitXml.outputLocation.set(project.file("build/test-results/jsDenoTest/")) + it.html.outputLocation.set(project.file("build/reports/tests/jsDenoTest/")) + } + binaryResultsDirectory.set(project.file("build/test-results/jsDenoTest/binary")) + //reports.enabledReports["junitXml"]!!.optional + //reports.junitXml.outputLocation.opt + //reports.enabledReports.clear() + //reports.junitXml.outputLocation.set(project.file("build/deno-test-results")) + } + + override fun createTestExecuter(): TestExecuter { + return DenoTestExecuter(this.project, this.filter) + } + //override fun createTestExecuter(): TestExecuter = TODO() + override fun createTestExecutionSpec(): TestExecutionSpec = DenoTestExecutionSpec() + + init { + outputs.upToDateWhen { false } + } + + class DenoTestExecuter(val project: Project, val filter: TestFilter) : TestExecuter { + private fun Project.fullPathName(): String { + //KotlinTest + if (this.parent == null) return this.name + return this.parent!!.fullPathName() + ":" + this.name + } + + override fun execute(testExecutionSpec: DenoTestExecutionSpec, testResultProcessor: TestResultProcessor) { + val baseTestFileNameBase = this.project.fullPathName().trim(':').replace(':', '-') + "-test" + val baseTestFileName = "$baseTestFileNameBase.mjs" + val runFile = File(this.project.rootProject.rootDir, "build/js/packages/$baseTestFileNameBase/kotlin/$baseTestFileName.deno.mjs") + + runFile.parentFile.mkdirs() + runFile.writeText( + //language=js + """ + var describeStack = [] + globalThis.describe = (name, callback) => { describeStack.push(name); try { callback() } finally { describeStack.pop() } } + globalThis.it = (name, callback) => { return Deno.test({ name: describeStack.join(".") + "." + name, fn: callback}) } + globalThis.xit = (name, callback) => { return Deno.test({ name: describeStack.join(".") + "." + name, ignore: true, fn: callback}) } + function exists(path) { try { Deno.statSync(path); return true } catch (e) { return false } } + // Polyfill required for kotlinx-coroutines that detects window + window.postMessage = (message, targetOrigin) => { const ev = new Event('message'); ev.source = window; ev.data = message; window.dispatchEvent(ev); } + const file = './${baseTestFileName}'; + if (exists(file)) await import(file) + """.trimIndent()) + + //testResultProcessor.started() + val process = ProcessBuilder(buildList { + add("deno") + add("test") + add("--unstable-ffi") + add("--unstable-webgpu") + add("-A") + if (filter.includePatterns.isEmpty()) { + add("--filter=${filter.includePatterns.joinToString(",")}") + } + add("--junit-path=${project.file("build/test-results/jsDenoTest/junit.xml").absolutePath}") + add(runFile.absolutePath) + }).directory(runFile.parentFile) + .start() + var id = 0 + val buffered = process.inputStream.bufferedReader() + var capturingOutput = false + var currentTestId: String? = null + var currentTestExtra: String = "ok" + var failedCount = 0 + + fun flush() { + if (currentTestId != null) { + try { + val type = when { + currentTestExtra.contains("skip", ignoreCase = true) || currentTestExtra.contains("ignored", ignoreCase = true) -> TestResult.ResultType.SKIPPED + currentTestExtra.contains("error", ignoreCase = true) || currentTestExtra.contains("failed", ignoreCase = true) -> TestResult.ResultType.FAILURE + currentTestExtra.contains("ok", ignoreCase = true) -> TestResult.ResultType.SUCCESS + else -> TestResult.ResultType.SUCCESS + } + if (type == TestResult.ResultType.FAILURE) { + testResultProcessor.output(currentTestId, DefaultTestOutputEvent(TestOutputEvent.Destination.StdErr, "FAILED\n")) + testResultProcessor.failure(currentTestId, DefaultTestFailure.fromTestFrameworkFailure(Exception("FAILED").also { it.stackTrace = arrayOf() }, null)) + failedCount++ + } + testResultProcessor.completed(currentTestId, TestCompleteEvent(System.currentTimeMillis(), type)) + } catch (e: Throwable) { + //System.err.println("COMPLETED_ERROR: ${e}") + e.printStackTrace() + } + currentTestId = null + } + } + + testResultProcessor.started(DefaultTestSuiteDescriptor("deno", "deno"), TestStartEvent(System.currentTimeMillis())) + + for (line in buffered.lines()) { + println("::: $line") + when { + line == "------- output -------" -> { + capturingOutput = true + } + line == "----- output end -----" -> { + capturingOutput = false + } + capturingOutput -> { + testResultProcessor.output(currentTestId, DefaultTestOutputEvent(TestOutputEvent.Destination.StdOut, "$line\n")) + } + line.contains("...") -> { + //DefaultNestedTestSuiteDescriptor() + flush() + val (name, extra) = line.split("...").map { it.trim() } + //currentTestId = "$name${id++}" + currentTestId = "deno.myid${id++}" + //val demo = CompositeId("Unit", "Name${id++}") + //val descriptor = DefaultTestMethodDescriptor(currentTestId, name.substringBeforeLast('.'), name.substringAfterLast('.')) + + val descriptor = DefaultTestMethodDescriptor(currentTestId, name.substringBeforeLast('.'), name) + currentTestExtra = extra + testResultProcessor.started( + descriptor, + TestStartEvent(System.currentTimeMillis()) + ) + } + } + } + flush() + + testResultProcessor.completed("deno", TestCompleteEvent(System.currentTimeMillis(), if (failedCount == 0) TestResult.ResultType.SUCCESS else TestResult.ResultType.FAILURE)) + + process.waitFor() + System.err.print(process.errorStream.readBytes().decodeToString()) + } + + override fun stopNow() { + } + } + + class DenoTestExecutionSpec : TestExecutionSpec +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/JavaScriptRun.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/JavaScriptRun.kt new file mode 100644 index 0000000000..cdb9871c2d --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/JavaScriptRun.kt @@ -0,0 +1,117 @@ +package korlibs.korge.gradle.targets.js + +import korlibs.* +import korlibs.korge.gradle.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.tasks.* +import org.jetbrains.kotlin.gradle.targets.js.npm.tasks.* +import java.io.* +import java.lang.management.* + +internal var _webServer: DecoratedHttpServer? = null + +open class RunJsServer : DefaultTask() { + @get:Input + var blocking: Boolean = true + + private var webBindAddress: String = "127.0.0.1" + private var webBindPort: Int = 8083 + + private lateinit var wwwFolder: File + + init { + project.afterEvaluate { + webBindAddress = project.korge.webBindAddress + webBindPort = project.korge.webBindPort + wwwFolder = File(project.buildDir, "www") + } + } + + @TaskAction + open fun run() { + if (_webServer == null) { + val address = webBindAddress + val port = webBindPort + val server = staticHttpServer(wwwFolder, address = address, port = port) + _webServer = server + try { + val openAddress = when (address) { + "0.0.0.0" -> "127.0.0.1" + else -> address + } + openBrowser("http://$openAddress:${server.port}/index.html") + if (blocking) { + while (true) { + Thread.sleep(1000L) + } + } + } finally { + if (blocking) { + println("Stopping web server...") + server.server.stop(0) + _webServer = null + } + } + } + _webServer?.updateVersion?.incrementAndGet() + } +} + +fun Project.fullPathName(): String { + if (this.parent == null) return this.name + return this.parent!!.fullPathName() + ":" + this.name +} + +fun Project.configureJavascriptRun() { + val runJsRelease = project.tasks.createThis(name = "runJsRelease") { + group = GROUP_KORGE_RUN + dependsOn("browserReleaseEsbuild") + blocking = !project.gradle.startParameter.isContinuous + } + + val runJsDebug = project.tasks.createThis("runJsDebug") { + group = GROUP_KORGE_RUN + dependsOn("browserDebugEsbuild") + blocking = !project.gradle.startParameter.isContinuous + } + + // @TODO: jsBrowserProductionRun is much faster than jsBrowserDevelopmentRun at runtime. Why is that?? + val runJs = project.tasks.createThis("runJs") { + group = GROUP_KORGE_RUN + dependsOn(runJsDebug) + } + + /* + val runJsWebpack = project.tasks.createThis(name = "runJsWebpack") { + group = GROUP_KORGE_RUN + dependsOn("jsBrowserProductionRun") + } + + val runJsWebpackDebug = project.tasks.createThis(name = "runJsWebpackDebug") { + group = GROUP_KORGE_RUN + dependsOn("jsBrowserDevelopmentRun") + } + + val runJsWebpackRelease = project.tasks.createThis(name = "runJsWebpackRelease") { + group = GROUP_KORGE_RUN + dependsOn("jsBrowserProductionRun") + } + */ + + val jsStopWeb = project.tasks.createThis(name = "jsStopWeb") { + doLast { + println("jsStopWeb: ${ManagementFactory.getRuntimeMXBean().name}-${Thread.currentThread()}") + _webServer?.server?.stop(0) + _webServer = null + } + } + + // https://blog.jetbrains.com/kotlin/2021/10/control-over-npm-dependencies-in-kotlin-js/ + allprojects { + tasks.withType(KotlinNpmInstallTask::class.java).allThis { + args += "--ignore-scripts" + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/Wasm.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/Wasm.kt new file mode 100644 index 0000000000..03733402df --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/Wasm.kt @@ -0,0 +1,55 @@ +package korlibs.korge.gradle.targets.js + +import korlibs.korge.gradle.gkotlin +import korlibs.korge.gradle.kotlin +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.jvm.* +import korlibs.korge.gradle.targets.wasm.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.tasks.TaskAction +import org.jetbrains.kotlin.gradle.targets.js.npm.* +import java.io.* + +fun Project.configureWasm(projectType: ProjectType, binaryen: Boolean = false) { + if (gkotlin.targets.findByName("wasm") != null) return + ensureSourceSetsConfigure("common", "wasmJs") + + configureWasmTarget(executable = true, binaryen = binaryen) + + if (projectType.isExecutable) { + + val wasmJsCreateIndex = project.tasks.createThis("wasmJsCreateIndex") { + } + //:compileDevelopmentExecutableKotlinWasmJs + //project.tasks.findByName("wasmJsBrowserDevelopmentRun")?.apply { + project.tasks.findByName("compileDevelopmentExecutableKotlinWasmJs")?.apply { + dependsOn(wasmJsCreateIndex) + } + project.tasks.findByName("compileProductionExecutableKotlinWasmJs")?.apply { + dependsOn(wasmJsCreateIndex) + } + project.tasks.createThis("runWasmJs") { + dependsOn("wasmJsRun") + } + } +} + +open class WasmJsCreateIndexTask : DefaultTask() { + private val npmDir: File = project.kotlin.wasmJs().compilations["main"]!!.npmProject.dir.get().asFile + + @TaskAction + fun run() { + File(npmDir, "kotlin/index.html").also { it.parentFile.mkdirs() }.writeText( + """ + + + + """.trimIndent() + ) + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/Webpack.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/Webpack.kt new file mode 100644 index 0000000000..f9ef9acf67 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/js/Webpack.kt @@ -0,0 +1,10 @@ +package korlibs.korge.gradle.targets.js + +import org.gradle.api.* + +fun Project.configureWebpackFixes() { +// @TODO: HACK for webpack: https://youtrack.jetbrains.com/issue/KT-48273#focus=Comments-27-5122487.0-0 + rootProject.plugins.withType(org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin::class.java) { + //rootProject.extensions.getByType(org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension::class.java).versions.webpackDevServer.version = "4.0.0-rc.0" + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/Jvm.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/Jvm.kt new file mode 100644 index 0000000000..64ab810915 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/Jvm.kt @@ -0,0 +1,357 @@ +package korlibs.korge.gradle.targets.jvm + +import korlibs.* +import korlibs.korge.gradle.* +import korlibs.korge.gradle.gkotlin +import korlibs.korge.gradle.kotlin +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.desktop.* +import korlibs.korge.gradle.util.* +import korlibs.root.* +import org.gradle.api.* +import org.gradle.api.file.* +import org.gradle.api.tasks.* +import org.gradle.api.tasks.bundling.* +import org.gradle.api.tasks.testing.* +import org.gradle.jvm.tasks.Jar +import org.jetbrains.kotlin.gradle.plugin.mpp.* +import proguard.gradle.* +import java.io.* + +val KORGE_RELOAD_AGENT_CONFIGURATION_NAME = "KorgeReloadAgent" +val httpPort = 22011 + +fun Project.ensureSourceSetsConfigure(vararg names: String) { + val sourceSets = project.kotlin.sourceSets + for (name in names) { + sourceSets.createPairSourceSet(name, project = project) + } +} + +fun Project.configureJvm(projectType: ProjectType) { + if (gkotlin.targets.findByName("jvm") != null) return + + val jvmTarget = gkotlin.jvm() + gkotlin.targets.add(jvmTarget) + + project.korge.addDependency("jvmMainImplementation", "net.java.dev.jna:jna:$jnaVersion") + project.korge.addDependency("jvmMainImplementation", "net.java.dev.jna:jna-platform:$jnaVersion") + project.korge.addDependency("jvmMainImplementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + gkotlin.jvm { + testRuns["test"].executionTask.configure { + it.useJUnit() + //it.useJUnitPlatform() + } + } + project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).allThis { + kotlinOptions { + this.jvmTarget = korge.jvmTarget + } + } + + if (projectType.isExecutable) { + configureJvmRunJvm(isRootKorlibs = false) + } + addProguard() + configureJvmTest() + + val jvmProcessResources = tasks.findByName("jvmProcessResources") as? Copy? + jvmProcessResources?.duplicatesStrategy = org.gradle.api.file.DuplicatesStrategy.INCLUDE + + ensureSourceSetsConfigure("common", "jvm") +} + +fun Project.configureJvmRunJvm(isRootKorlibs: Boolean) { + val project = this + + val timeBeforeCompilationFile = File(project.buildDir, "timeBeforeCompilation") + + project.tasks.createThis("compileKotlinJvmAndNotifyBefore") { + doFirst { + KorgeReloadNotifier.beforeBuild(timeBeforeCompilationFile) + } + } + afterEvaluate { + tasks.findByName("compileKotlinJvm")?.mustRunAfter("compileKotlinJvmAndNotifyBefore") + } + project.tasks.createThis("compileKotlinJvmAndNotify") { + dependsOn("compileKotlinJvmAndNotifyBefore", "compileKotlinJvm") + doFirst { + KorgeReloadNotifier.afterBuild(timeBeforeCompilationFile, httpPort) + } + } + + fun generateEntryPoint(entry: KorgeExtension.Entrypoint) { + val capitalizedEntryName = entry.name.capitalize() + project.tasks.createThis("runJvm${capitalizedEntryName}") { + group = GROUP_KORGE_RUN + dependsOn("jvmMainClasses") + project.afterEvaluate { + mainClass.set(entry.jvmMainClassName()) + val beforeJava9 = JvmAddOpens.beforeJava9 + if (!beforeJava9) jvmArgs(project.korge.javaAddOpens) + } + } + //for (enableRedefinition in listOf(false, true)) { + for (enableRedefinition in listOf(false)) { + val taskName = when (enableRedefinition) { + false -> "runJvm${capitalizedEntryName}Autoreload" + true -> "runJvm${capitalizedEntryName}AutoreloadWithRedefinition" + } + project.tasks.createThis(taskName) { + this.enableRedefinition = enableRedefinition + group = GROUP_KORGE_RUN + dependsOn("jvmMainClasses", "compileKotlinJvm") + project.afterEvaluate { + if (isRootKorlibs) { + dependsOn(":korge-reload-agent:jar") + } + val beforeJava9 = JvmAddOpens.beforeJava9 + if (!beforeJava9) jvmArgs(project.korge.javaAddOpens) + mainClass.set(korge.jvmMainClassName) + } + } + } + } + + if (!isRootKorlibs) { + project.configurations + .create(KORGE_RELOAD_AGENT_CONFIGURATION_NAME) + //.setVisible(false) + //.setTransitive(true) + //.setDescription("korge-reload-agent to be downloaded and used for this project.") + project.dependencies { + + add(KORGE_RELOAD_AGENT_CONFIGURATION_NAME, "${RootKorlibsPlugin.KORGE_RELOAD_AGENT_GROUP}:korge-reload-agent:${BuildVersions.KORGE}") + } + } + + // runJvm, runJvmAutoreload and variants for entrypoints + // Immediately generate `runJvm` + generateEntryPoint(korge.getDefaultEntryPoint()) + project.afterEvaluate { + // And after the first evaluation when `korge {}` block must have been executed, generate the rest of the entrypoints + // https://www.baeldung.com/java-instrumentation + for (entry in korge.extraEntryPoints) { + generateEntryPoint(entry) + } + } + + project.tasks.findByName("jvmJar")?.let { + (it as Jar).apply { + entryCompression = ZipEntryCompression.STORED + } + } +} + +internal val Project.jvmCompilation: NamedDomainObjectSet<*> get() = kotlin.targets.getByName("jvm").compilations as NamedDomainObjectSet<*> +internal val Project.mainJvmCompilation: KotlinJvmCompilation get() = jvmCompilation.getByName("main") as? org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJvmCompilation? ?: error("Can't find main jvm compilation") + +private fun Project.configureJvmTest() { + val jvmTest = (tasks.findByName("jvmTest") as Test) + jvmTest.classpath += project.files().from(project.getCompilationKorgeProcessedResourcesFolder(mainJvmCompilation)) + jvmTest.jvmArgs = (jvmTest.jvmArgs ?: listOf()) + listOf("-Djava.awt.headless=true") + + val jvmTestFix = tasks.createThis("jvmTestFix") { + group = "verification" + environment("UPDATE_TEST_REF", "true") + testClassesDirs = jvmTest.testClassesDirs + classpath = jvmTest.classpath + bootstrapClasspath = jvmTest.bootstrapClasspath + systemProperty("java.awt.headless", "true") + } +} + +private fun Project.addProguard() { + // packageJvmFatJar + val packageJvmFatJar = project.tasks.createThis("packageJvmFatJar") { + dependsOn("jvmJar") + //entryCompression = ZipEntryCompression.STORED + archiveBaseName.set("${project.name}-all") + group = GROUP_KORGE_PACKAGE + exclude( + "com/sun/jna/aix-ppc/**", + "com/sun/jna/aix-ppc64/**", + "com/sun/jna/freebsd-x86/**", + "com/sun/jna/freebsd-x86-64/**", + "com/sun/jna/linux-ppc/**", + "com/sun/jna/linux-ppc64le/**", + "com/sun/jna/linux-s390x/**", + "com/sun/jna/linux-mips64el/**", + "com/sun/jna/openbsd-x86/**", + "com/sun/jna/openbsd-x86-64/**", + "com/sun/jna/sunos-sparc/**", + "com/sun/jna/sunos-sparcv9/**", + "com/sun/jna/sunos-x86/**", + "com/sun/jna/sunos-x86-64/**", + "natives/macosx64/**", + "natives/macosarm64/**", + "META-INF/*.kotlin_module", + ) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + project.afterEvaluate { + manifest { manifest -> + manifest.attributes( + mapOf( + "Implementation-Title" to korge.realJvmMainClassName, + "Implementation-Version" to project.version.toString(), + "Main-Class" to korge.realJvmMainClassName, + "Add-Opens" to JvmAddOpens.jvmAddOpensList().joinToString(" "), + ) + ) + } + //it.from() + //fileTree() + from(closure { + project.gkotlin.targets.jvm.compilations.main.runtimeDependencyFiles.map { if (it.isDirectory) it else project.zipTree(it) as Any } + //listOf() + }) + //val jvmJarTask = project.getTasksByName("jvmJar", true).first { it.project == project } as CopySpec + val jvmJarTask = project.getTasksByName("jvmJar", false).first() as Jar + //jvmJarTask.entryCompression = ZipEntryCompression.STORED + with(jvmJarTask) + from(project.files().from(project.getCompilationKorgeProcessedResourcesFolder(mainJvmCompilation))) + //println("jvmJarTask=$jvmJarTask") + } + } + + val packageJvmFatJarProguard = project.tasks.createThis("packageJvmFatJarProguard") { + dependsOn(packageJvmFatJar) + group = GROUP_KORGE_PACKAGE + val serializationProFile = File(buildDir, "/serialization.pro") + + doFirst { + serializationProFile.writeTextIfChanged(""" + # Keep `Companion` object fields of serializable classes. + # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. + -if @kotlinx.serialization.Serializable class ** + -keepclassmembers class <1> { + static <1>${'$'}Companion Companion; + } + + # Keep `serializer()` on companion objects (both default and named) of serializable classes. + -if @kotlinx.serialization.Serializable class ** { + static **${'$'}* *; + } + -keepclassmembers class <2>${'$'}<3> { + kotlinx.serialization.KSerializer serializer(...); + } + + # Keep `INSTANCE.serializer()` of serializable objects. + -if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; + } + -keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); + } + + # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. + -keepattributes RuntimeVisibleAnnotations,AnnotationDefault + + # Don't print notes about potential mistakes or omissions in the configuration for kotlinx-serialization classes + # See also https://github.com/Kotlin/kotlinx.serialization/issues/1900 + -dontnote kotlinx.serialization.** + + # Serialization core uses `java.lang.ClassValue` for caching inside these specified classes. + # If there is no `java.lang.ClassValue` (for example, in Android), then R8/ProGuard will print a warning. + # However, since in this case they will not be used, we can disable these warnings + -dontwarn kotlinx.serialization.internal.ClassValueReferences + """.trimIndent()) + } + project.afterEvaluate { + val javaHome = System.getProperty("java.home") + libraryjars("$javaHome/lib/rt.jar") + // Support newer java versions that doesn't have rt.jar + libraryjars(project.fileTree("$javaHome/jmods/") { + it.include("**/java.*.jmod") + }) + //println(packageJvmFatJar.outputs.files.toList()) + injars(packageJvmFatJar.outputs.files.toList()) + outjars(File(buildDir, "/libs/${project.name}-all-proguard.jar")) + dontwarn() + ignorewarnings() + if (!project.korge.proguardObfuscate) { + dontobfuscate() + } + assumenosideeffects(""" + class kotlin.jvm.internal.Intrinsics { + static void checkParameterIsNotNull(java.lang.Object, java.lang.String); + } + """.trimIndent()) + + this.configuration(serializationProFile) + + keepnames("class com.sun.jna.** { *; }") + keepnames("class * extends com.sun.jna.** { *; }") + keepnames("class * implements com.sun.jna.Library { *; }") + keepnames("class * extends korlibs.ffi.FFILib { *; }") + keepnames("class * extends korlibs.korge.scene.Scene { *; }") + keepnames("@korlibs.io.annotations.Keep class * { *; }") + keepnames("@korlibs.annotations.Keep class * { *; }") + keepnames("@korlibs.annotations.KeepNames class * { *; }") + keepnames("@kotlinx.serialization class * { *; }") + keepclassmembernames("class * { @korlibs.io.annotations.Keep *; }") + keepclassmembernames("@korlibs.io.annotations.Keep class * { *; }") + keepclassmembernames("@korlibs.io.annotations.Keep interface * { *; }") + keepclassmembernames("@korlibs.annotations.Keep class * { *; }") + keepclassmembernames("@korlibs.annotations.Keep interface * { *; }") + keepclassmembernames("@korlibs.annotations.KeepNames class * { *; }") + keepclassmembernames("@korlibs.annotations.KeepNames interface * { *; }") + keepclassmembernames("enum * { public *; }") + //keepnames("@korlibs.io.annotations.Keep interface *") + //keepnames("class korlibs.render.platform.INativeGL") + + + //task.keepnames("class org.jcodec.** { *; }") + keepattributes() + keep("class * extends korlibs.ffi.FFILib { *; }") + keep("class * implements com.sun.jna.** { *; }") + keep("class com.sun.jna.** { *; }") + keep("class org.jcodec.** { *; }") + //keep("class korlibs.ffi.** { *; }") + keep("@korlibs.io.annotations.Keep class * { *; }") + keep("@korlibs.annotations.Keep class * { *; }") + keep("@kotlinx.serialization class * { *; }") + keep("class * implements korlibs.korge.ViewsCompleter { *; }") + keep("public class kotlin.reflect.jvm.internal.impl.serialization.deserialization.builtins.* { public *; }") + keep("class kotlin.reflect.jvm.internal.impl.load.** { *; }") + + + if (korge.realJvmMainClassName.isNotBlank()) { + keep("class ${project.korge.realJvmMainClassName} { *; }") + keep("""public class ${korge.realJvmMainClassName} { public static void main(java.lang.String[]); }""") + } + } + } + + val packageFatJar = packageJvmFatJar + //val packageFatJar = packageJvmFatJarProguard + + + project.tasks.createThis("packageJvmLinuxApp") { + dependsOn(packageFatJar) + group = GROUP_KORGE_PACKAGE + doLast { + DesktopJreBundler.createLinuxBundle(project, packageFatJar.outputs.files.first()) + } + } + + project.tasks.createThis("packageJvmWindowsApp") { + dependsOn(packageFatJar) + group = GROUP_KORGE_PACKAGE + doLast { + DesktopJreBundler.createWin32Bundle(project, packageFatJar.outputs.files.first()) + } + } + + project.tasks.createThis("packageJvmMacosApp") { + dependsOn(packageFatJar) + group = GROUP_KORGE_PACKAGE + doLast { + DesktopJreBundler.createMacosApp(project, packageFatJar.outputs.files.first()) + } + } + +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/JvmAddOpens.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/JvmAddOpens.kt new file mode 100644 index 0000000000..2cb2645b07 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/JvmAddOpens.kt @@ -0,0 +1,31 @@ +package korlibs.korge.gradle.targets.jvm + +import korlibs.korge.gradle.targets.* + +object JvmAddOpens { + val beforeJava9 = System.getProperty("java.version").startsWith("1.") + + fun createAddOpensTypedArray(): Array = createAddOpens().toTypedArray() + + fun jvmAddOpensList(mac: Boolean = true, linux: Boolean = true): List = buildList { + add("java.desktop/sun.java2d.opengl") + add("java.desktop/java.awt") + add("java.desktop/sun.awt") + if (mac) { + add("java.desktop/sun.lwawt") + add("java.desktop/sun.lwawt.macosx") + add("java.desktop/com.apple.eawt") + add("java.desktop/com.apple.eawt.event") + } + if (linux) { + add("java.desktop/sun.awt.X11") + } + } + + @OptIn(ExperimentalStdlibApi::class) + fun createAddOpens(): List = buildList { + for (item in jvmAddOpensList(mac = isMacos, linux = isLinux)) { + add("--add-opens=$item=ALL-UNNAMED") + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/JvmTools.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/JvmTools.kt new file mode 100644 index 0000000000..025467950d --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/JvmTools.kt @@ -0,0 +1,48 @@ +package korlibs.korge.gradle.targets.jvm + +import java.io.File +import java.io.UnsupportedEncodingException +import java.net.URLDecoder +import java.nio.charset.Charset + +object JvmTools { + /** + * If the provided class has been loaded from a jar file that is on the local file system, will find the absolute path to that jar file. + * + * @param context The jar file that contained the class file that represents this class will be found. Specify `null` to let `LiveInjector` + * find its own jar. + * @throws IllegalStateException If the specified class was loaded from a directory or in some other way (such as via HTTP, from a database, or some + * other custom classloading device). + */ + @Throws(IllegalStateException::class) + fun findPathJar(context: Class<*>): String? { + val path = context.getProtectionDomain().getCodeSource().getLocation().getPath() + if (path != null) return path + val rawName = context.getName() + var classFileName: String + /* rawName is something like package.name.ContainingClass$ClassName. We need to turn this into ContainingClass$ClassName.class. */run { + val idx: Int = rawName.lastIndexOf('.') + classFileName = (if (idx == -1) rawName else rawName.substring(idx + 1)) + ".class" + } + val uri = context.getResource(classFileName).toString() + if (uri.startsWith("file:")) throw IllegalStateException("This class has been loaded from a directory and not from a jar file.") + if (!uri.startsWith("jar:file:")) { + val idx = uri.indexOf(':') + val protocol = if (idx == -1) "(unknown)" else uri.substring(0, idx) + throw IllegalStateException( + "This class has been loaded remotely via the " + protocol + + " protocol. Only loading from a jar on the local file system is supported." + ) + } + val idx = uri.indexOf('!') + //As far as I know, the if statement below can't ever trigger, so it's more of a sanity check thing. + if (idx == -1) throw IllegalStateException("You appear to have loaded this class from a local jar file, but I can't make sense of the URL!") + try { + val fileName: String = + URLDecoder.decode(uri.substring("jar:file:".length, idx), Charset.defaultCharset().name()) + return File(fileName).getAbsolutePath() + } catch (e: UnsupportedEncodingException) { + throw InternalError("default charset doesn't exist. Your VM is borked.") + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/KorgeJavaExec.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/KorgeJavaExec.kt new file mode 100644 index 0000000000..6450b219cc --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/jvm/KorgeJavaExec.kt @@ -0,0 +1,219 @@ +package korlibs.korge.gradle.targets.jvm + +import korlibs.korge.gradle.* +import korlibs.korge.gradle.targets.* +import org.gradle.api.* +import org.gradle.api.artifacts.* +import org.gradle.api.file.* +import org.gradle.api.tasks.* +import org.gradle.jvm.tasks.* +import java.io.* + +fun Project.findAllProjectDependencies(visited: MutableSet = mutableSetOf()): Set { + if (this in visited) return visited + visited.add(this) + val dependencies = project.configurations.flatMap { it.dependencies.withType(ProjectDependency::class.java) }.filterIsInstance() + return (dependencies.flatMap { it.dependencyProject.findAllProjectDependencies(visited) } + this).toSet() +} + +open class KorgeJavaExecWithAutoreload : KorgeJavaExec() { + @get:Input + var enableRedefinition: Boolean = false + + @get:Input + var doConfigurationCache: Boolean = true + + companion object { + const val ARGS_SEPARATOR = "<:/:>" + const val CMD_SEPARATOR = "<@/@>" + } + + private lateinit var projectPaths: List + private var rootDir: File = project.rootProject.rootDir + @get:InputFiles + lateinit var rootJars: List + + //@get:InputFile + //private var reloadAgentConfiguration: Configuration = project.configurations.getByName(KORGE_RELOAD_AGENT_CONFIGURATION_NAME)//.resolve().first() + //lateinit var reloadAgentJar: File + + + init { + //val reloadAgent = project.findProject(":korge-reload-agent") + //if (reloadAgent != null) + //project.dependencies.add() + + /* + project.afterEvaluate { + project.afterEvaluate { + project.afterEvaluate { + //project.configurations.getByName("compile") + //println("*****" + project.findAllProjectDependencies()) + val allProjects = project.findAllProjectDependencies() + val allProjectsWithCompileKotinJvm = allProjects.filter { it.tasks.findByName("compileKotlinJvm") != null } + projectPaths = allProjectsWithCompileKotinJvm.map { it.path } + rootJars = allProjectsWithCompileKotinJvm + .mapNotNull { (it.tasks.findByName("compileKotlinJvm") as? org.jetbrains.kotlin.gradle.tasks.KotlinCompile?)?.outputs?.files } + .reduce { a, b -> a + b } + println("allProjects=${allProjects.map { it.name }}") + println("allProjectsWithCompileKotinJvm=${allProjectsWithCompileKotinJvm.map { it.name }}") + println("rootJars=\n${rootJars.toList().joinToString("\n")}") + //println("::::" + project.configurations.toList()) + } + } + } + */ + val reloadAgent = project.findProject(":korge-reload-agent") + val reloadAgentJar = when { + reloadAgent != null -> (project.rootProject.tasks.getByPath(":korge-reload-agent:jar") as Jar).outputs.files.files.first() + else -> project.configurations.getByName(KORGE_RELOAD_AGENT_CONFIGURATION_NAME).resolve().first() + } + + project.afterEvaluate { + val allProjects = project.findAllProjectDependencies() + //projectPaths = allProjects.map { it.path } + projectPaths = listOf(project.path) + rootJars = allProjects.map { File(it.buildDir, "classes/kotlin/jvm/main") } + //println("allProjects=${allProjects.map { it.name }}") + //println("projectPaths=$projectPaths") + //println("rootJars=\n${rootJars.toList().joinToString("\n")}") + + //println("runJvmAutoreload:reloadAgentJar=$reloadAgentJar") + //val outputJar = JvmTools.findPathJar(Class.forName("korlibs.korge.reloadagent.KorgeReloadAgent")) + + //val agentJarTask: org.gradle.api.tasks.bundling.Jar = project(":korge-reload-agent").tasks.findByName("jar") as org.gradle.api.tasks.bundling.Jar + //val outputJar = agentJarTask.outputs.files.files.first() + //println("agentJarTask=$outputJar") + + jvmArgs( + "-javaagent:$reloadAgentJar=${listOf( + "$httpPort", + ArrayList().apply { + add("-classpath") + add("${rootDir}/gradle/wrapper/gradle-wrapper.jar") + add("org.gradle.wrapper.GradleWrapperMain") + //add("--no-daemon") // This causes: Continuous build does not work when file system watching is disabled + add("--watch-fs") + add("--warn") + add("--project-dir=${rootDir}") + if (doConfigurationCache) { + add("--configuration-cache") + add("--configuration-cache-problems=warn") + } + add("-t") + add("compileKotlinJvm") + //add("compileKotlinJvmAndNotify") + for (projectPath in projectPaths) { + add("${projectPath.trimEnd(':')}:compileKotlinJvmAndNotify") + } + }.joinToString(CMD_SEPARATOR), + "$enableRedefinition", + rootJars.joinToString(CMD_SEPARATOR) { it.absolutePath } + ).joinToString(ARGS_SEPARATOR)}" + ) + + environment("KORGE_AUTORELOAD", "true") + environment("KORGE_IPC", project.findProperty("korge.ipc")?.toString()) + environment("KORGE_HEADLESS", project.findProperty("korge.headless")?.toString()) + } + } +} + +fun Project.getKorgeClassPath(): FileCollection { + return ArrayList().apply { + val mainJvmCompilation = project.mainJvmCompilation + add(mainJvmCompilation.runtimeDependencyFiles) + add(mainJvmCompilation.compileDependencyFiles) + //if (project.korge.searchResourceProcessorsInMainSourceSet) { + add(mainJvmCompilation.output.allOutputs) + add(mainJvmCompilation.output.classesDirs) + //} + //project.kotlin.jvm() + add(project.files().from(project.getCompilationKorgeProcessedResourcesFolder(mainJvmCompilation))) + //add(project.files().from((project.tasks.findByName(jvmProcessedResourcesTaskName) as KorgeProcessedResourcesTask).processedResourcesFolder)) + } + .reduceRight { l, r -> l + r } +} + +open class KorgeJavaExec : JavaExec() { + //dependsOn(getKorgeProcessResourcesTaskName("jvm", "main")) + + @get:Input + var logLevel = "info" + + @get:InputFiles + val korgeClassPath: FileCollection = project.getKorgeClassPath() + + override fun exec() { + classpath = korgeClassPath + for (classPath in classpath.toList()) { + logger.info("- $classPath") + } + environment("LOG_LEVEL", logLevel) + super.exec() + } + + @get:Input + @Optional + var firstThread: Boolean? = null + + init { + systemProperties = (System.getProperties().toMutableMap() as MutableMap) - "java.awt.headless" + defaultCharacterEncoding = Charsets.UTF_8.toString() + // https://github.com/korlibs/korge-plugins/issues/25 + val firstThread = firstThread + ?: ( + System.getenv("KORGE_START_ON_FIRST_THREAD") == "true" + || System.getenv("KORGW_JVM_ENGINE") == "sdl" + //|| project.findProperty("korgw.jvm.engine") == "sdl" + ) + if (javaVersion.isCompatibleWith(JavaVersion.VERSION_17)) { + jvmArgs("-XX:+UnlockExperimentalVMOptions", "-XX:+IgnoreUnrecognizedVMOptions", "-XX:+UseZGC", "-XX:+ZGenerational") + } + //jvmArgs("-XX:+UseZGC") + if (firstThread && isMacos) { + jvmArgs("-XstartOnFirstThread") + } + //jvmArgumentProviders.add(provider) + } +} + +/* +open class KorgeJavaExec : JavaExec() { + private val jvmCompilation get() = project.kotlin.targets.getByName("jvm").compilations as NamedDomainObjectSet<*> + private val mainJvmCompilation get() = jvmCompilation.getByName("main") as org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJvmCompilation + + @get:InputFiles + val korgeClassPath: FileCollection = mainJvmCompilation.runtimeDependencyFiles + mainJvmCompilation.compileDependencyFiles + mainJvmCompilation.output.allOutputs + mainJvmCompilation.output.classesDirs + + override fun exec() { + systemProperties = (System.getProperties().toMutableMap() as MutableMap) - "java.awt.headless" + if (!JvmAddOpens.beforeJava9) jvmArgs(*JvmAddOpens.createAddOpensTypedArray()) + classpath = korgeClassPath + super.exec() + //project.afterEvaluate { + //if (firstThread == true && OS.isMac) task.jvmArgs("-XstartOnFirstThread") + //} + } +} +*/ + +/* +open class KorgeJavaExec : JavaExec() { + private val jvmCompilation get() = project.kotlin.targets.getByName("jvm").compilations as NamedDomainObjectSet<*> + private val mainJvmCompilation get() = jvmCompilation.getByName("main") as org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJvmCompilation + + @get:InputFiles + val korgeClassPath: FileCollection = mainJvmCompilation.runtimeDependencyFiles + mainJvmCompilation.compileDependencyFiles + mainJvmCompilation.output.allOutputs + mainJvmCompilation.output.classesDirs + + override fun exec() { + systemProperties = (System.getProperties().toMutableMap() as MutableMap) - "java.awt.headless" + if (!JvmAddOpens.beforeJava9) jvmArgs(*JvmAddOpens.createAddOpensTypedArray()) + classpath = korgeClassPath + super.exec() + //project.afterEvaluate { + //if (firstThread == true && OS.isMac) task.jvmArgs("-XstartOnFirstThread") + //} + } +} +*/ diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/linux/LDLibraries.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/linux/LDLibraries.kt new file mode 100644 index 0000000000..dd06d6ca71 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/linux/LDLibraries.kt @@ -0,0 +1,58 @@ +package korlibs.korge.gradle.targets.linux + +import java.io.* +import java.nio.file.Files + +object LDLibraries { + private val libFolders = LinkedHashSet() + private val loadConfFiles = LinkedHashSet() + + val ldFolders: List get() = libFolders.toList() + + // /etc/ld.so.conf + // include /etc/ld.so.conf.d/*.conf + + fun addPath(path: String) { + val file = File(path) + if (file.isDirectory) { + libFolders.add(file) + } + } + + init { + try { + // Fixed paths as described https://renenyffenegger.ch/notes/Linux/fhs/etc/ld_so_conf + addPath("/lib") + addPath("/usr/lib") + // Load config file + loadConfFile(File("/etc/ld.so.conf")) + } catch (e: Throwable) { + e.printStackTrace() + } + } + + fun hasLibrary(name: String) = libFolders.any { File(it, name).exists() } + + private fun loadConfFile(file: File) { + if (file in loadConfFiles) return + loadConfFiles.add(file) + for (line in file.readLines()) { + val tline = line.trim().substringBefore('#').takeIf { it.isNotEmpty() } ?: continue + + if (tline.startsWith("include ")) { + val glob = tline.removePrefix("include ") + val globFolder = File(glob).parentFile + val globPattern = File(glob).name + if (globFolder.isDirectory) { + for (folder in + Files.newDirectoryStream(globFolder.toPath(), globPattern).toList().map { it.toFile() } + ) { + loadConfFile(folder) + } + } + } else { + addPath(tline) + } + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/native/KotlinNativeCrossTest.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/native/KotlinNativeCrossTest.kt new file mode 100644 index 0000000000..c590a49172 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/native/KotlinNativeCrossTest.kt @@ -0,0 +1,45 @@ +package korlibs.korge.gradle.targets.native + +import korlibs.korge.gradle.targets.* +import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.options.Option + +fun Exec.commandLineCross(vararg args: String, type: CrossExecType): CommandLineCrossResult { + val (result, array) = type.commands(*args) + commandLine(*array) + return result +} + +abstract class KotlinNativeCrossTest : org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest() { + @Input + @Option(option = "type", description = "Sets the executable cross type") + lateinit var type: CrossExecType + + @Internal + var debugMode = false + + @get:Internal + override val testCommand: TestCommand = object : TestCommand() { + val commands get() = type.commands().second + + override val executable: String + get() = commands.first() + + override fun cliArgs( + testLogger: String?, + checkExitCode: Boolean, + testGradleFilter: Set, + testNegativeGradleFilter: Set, + userArgs: List + ): List { + type.commands().first.ensure() + return listOfNotNull( + *commands.drop(1).toTypedArray(), + this@KotlinNativeCrossTest.executable.absolutePath, + ) + + testArgs(testLogger, checkExitCode, testGradleFilter, testNegativeGradleFilter, userArgs) + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/native/NativeBuildTypes.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/native/NativeBuildTypes.kt new file mode 100644 index 0000000000..c687e9bcf1 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/native/NativeBuildTypes.kt @@ -0,0 +1,12 @@ +package korlibs.korge.gradle.targets.native + +import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType + +object NativeBuildTypes { + val TYPES = listOf(NativeBuildType.DEBUG, NativeBuildType.RELEASE) +} + +val NativeBuildType.nameType: String get() = when (this) { + NativeBuildType.DEBUG -> "Debug" + NativeBuildType.RELEASE -> "Release" +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/native/NativeExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/native/NativeExt.kt new file mode 100644 index 0000000000..18ce099957 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/native/NativeExt.kt @@ -0,0 +1,33 @@ +package korlibs.korge.gradle.targets.native + +import org.gradle.api.* +import org.jetbrains.kotlin.gradle.plugin.* +import org.jetbrains.kotlin.gradle.plugin.mpp.* +import org.jetbrains.kotlin.gradle.targets.native.tasks.* +import org.jetbrains.kotlin.gradle.tasks.* + +fun KotlinNativeCompilation.getLinkTask(kind: NativeOutputKind, type: NativeBuildType, project: Project): KotlinNativeLink { + val taskName = "link${type.name.toLowerCase().capitalize()}${kind.name.toLowerCase().capitalize()}${target.name.capitalize()}" + val taskName2 = "link${target.name.capitalize()}" + + val tasks = listOf( + project.getTasksByName(taskName, true), + project.getTasksByName(taskName, false), + project.getTasksByName(taskName2, true), + project.getTasksByName(taskName2, false), + ).flatMap { it } + return (tasks.firstOrNull() as? KotlinNativeLink) + ?: error("Can't find [$taskName or $taskName2] from $tasks from ${project.tasks.map { it.name }}") +} + +fun KotlinNativeCompilation.getCompileTask(kind: NativeOutputKind, type: NativeBuildType, project: Project): Task { + val taskName = "compileKotlin${target.name.capitalize()}" + val tasks = (project.getTasksByName(taskName, true) + project.getTasksByName(taskName, false)).toList() + return (tasks.firstOrNull()) ?: error("Can't find $taskName from $tasks from ${project.tasks.map { it.name }}") +} + +val KotlinNativeTest.executableFolder get() = executable.parentFile ?: error("Can't get executable folder for KotlinNativeTest") + +fun KotlinTarget.configureKotlinNativeTarget(project: Project) { + // Do nothing for now +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/wasm/ConfigureWasm.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/wasm/ConfigureWasm.kt new file mode 100644 index 0000000000..0c58e6bb29 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/wasm/ConfigureWasm.kt @@ -0,0 +1,41 @@ +package korlibs.korge.gradle.targets.wasm + +import korlibs.* +import org.gradle.api.* + +//fun Project.isWasmEnabled(): Boolean = findProperty("enable.wasm") == "true" +fun isWasmEnabled(project: Project?): Boolean = true +//fun isWasmEnabled(project: Project?): Boolean = false +//fun Project.isWasmEnabled(): Boolean = false + +fun Project.configureWasmTarget(executable: Boolean, binaryen: Boolean = false) { + kotlin { + wasmJs { + if (executable) { + binaries.executable() + } + //applyBinaryen() + browser { + //commonWebpackConfig { experiments = mutableSetOf("topLevelAwait") } + if (executable) { + this.distribution { + } + } + //testTask { + // it.useKarma { + // //useChromeHeadless() + // this.webpackConfig.configDirectory = File(rootProject.rootDir, "karma.config.d") + // } + //} + } + + if (binaryen) applyBinaryen() + } + + sourceSets.maybeCreate("wasmJsTest").apply { + dependencies { + implementation("org.jetbrains.kotlin:kotlin-test-wasm-js") + } + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/windows/ICO2.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/windows/ICO2.kt new file mode 100644 index 0000000000..834666153a --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/windows/ICO2.kt @@ -0,0 +1,92 @@ +package korlibs.korge.gradle.targets.windows + +import korlibs.korge.gradle.util.* +import java.awt.image.* +import java.io.* +import kotlin.math.* + +@Suppress("UNUSED_VARIABLE") +object ICO2 { + // https://en.wikipedia.org/wiki/ICO_(file_format) + fun encode(images: List): ByteArray { + val s = ByteArrayOutputStream() + // 6 + s.write16LE(0) + s.write16LE(1) // ICO + s.write16LE(images.size) + + val payloadStart = 6 + 16 * images.size + val payloadData = ByteArrayOutputStream() + + // 16 per entry + for (frame in images) { + val bitmap = frame + val width = bitmap.width + val height = bitmap.height + if (width > 256 || height > 256) error("Size too big for ICO image: ${bitmap.width}x${bitmap.height}") + + s.write8(width) + s.write8(height) + s.write8(0) // Palette size + s.write8(0) // Reserved + s.write16LE(1) // Color planes + s.write16LE(32) // Bits per pixel + + val start = payloadData.size().toInt() + if (width == 32 && height == 32) { + val bmp = BMP2.encode(bitmap) + payloadData.writeBytes(bmp.sliceArray(14 until bmp.size)) + payloadData.writeBytes(ByteArray(width * height / 8)) + } else { + payloadData.writeBytes(bitmap.encodePNG()) + } + val size = payloadData.size().toInt() - start + + s.write32LE(size) + s.write32LE(payloadStart + start) + } + + s.writeBytes(payloadData.toByteArray()) + return s.toByteArray() + } +} + +@Suppress("UNUSED_VARIABLE") +object BMP2 { + fun encode(image: BufferedImage): ByteArray { + val bmp = image + val s = ByteArrayOutputStream(64 + bmp.area * 4) + + // + s.write8('B'.toInt()) + s.write8('M'.toInt()) + s.write32LE(4 * bmp.area) + s.write32LE(0) // Reserved + s.write32LE(54) // Offset to data + + s.write32LE(40) + s.write32LE(bmp.width) + s.write32LE(bmp.height * 2) + s.write16LE(1) // Planes + s.write16LE(32) // Bit count + s.write32LE(0) // Compression + s.write32LE(4 * bmp.area) // Size + s.write32LE(2834) // Pels per meter + s.write32LE(2834) // Pels per meter + s.write32LE(0) // Clr used + s.write32LE(0) // Important + //s.writeBytes(BGRA.encode(bmp.data)) + + val ints = (image.data.dataBuffer as DataBufferInt).data + + for (n in 0 until bmp.height) { + val y = bmp.height - 1 - n + for (x in 0 until bmp.width) { + // @TODO: Check this + s.write32LE(ints[y * bmp.width + x]) + } + //s.writeBytes(BGRA.encode(bmp.data, y * bmp.width, bmp.width, littleEndian = true)) + } + return s.toByteArray() + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/windows/RC.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/windows/RC.kt new file mode 100644 index 0000000000..b1286332c8 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/windows/RC.kt @@ -0,0 +1,43 @@ +package korlibs.korge.gradle.targets.windows + +import korlibs.korge.gradle.KorgeExtension +import java.io.* +import korlibs.korge.gradle.util.* + +object WindowsRC { + fun generate(info: KorgeExtension): String = kotlin.text.buildString { + appendLine("1000 ICON \"icon.ico\"") + appendLine("") + // https://docs.microsoft.com/en-us/windows/win32/menurc/versioninfo-resource + appendLine("1 VERSIONINFO") + appendLine("FILEVERSION 1,0,0,0") + appendLine("PRODUCTVERSION 1,0,0,0") + appendLine("BEGIN") + appendLine(" BLOCK \"StringFileInfo\"") + appendLine(" BEGIN") + appendLine(" BLOCK \"080904E4\"") + appendLine(" BEGIN") + appendLine(" VALUE \"CompanyName\", ${info.authorName.quoted}") + appendLine(" VALUE \"FileDescription\", ${info.description.quoted}") + appendLine(" VALUE \"FileVersion\", ${info.version.quoted}") + appendLine(" VALUE \"FileVersion\", ${info.version.quoted}") + appendLine(" VALUE \"InternalName\", ${info.name.quoted}") + appendLine(" VALUE \"LegalCopyright\", ${info.copyright.quoted}") + appendLine(" VALUE \"OriginalFilename\", ${info.exeBaseName.quoted}") + appendLine(" VALUE \"ProductName\", ${info.name.quoted}") + appendLine(" VALUE \"ProductVersion\", ${info.version.quoted}") + appendLine(" END") + appendLine(" END") + appendLine(" BLOCK \"VarFileInfo\"") + appendLine(" BEGIN") + appendLine(" VALUE \"Translation\", 0x809, 1252") + appendLine(" END") + appendLine("END") + } +} + +private fun StringBuilder.appendLine(value: String?): StringBuilder { + if (value != null) append(value) + append('\n') + return this +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/windows/WindowsToolchain.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/windows/WindowsToolchain.kt new file mode 100644 index 0000000000..d4d57fb86e --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/targets/windows/WindowsToolchain.kt @@ -0,0 +1,109 @@ +package korlibs.korge.gradle.targets.windows + +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.util.* +import korlibs.korge.gradle.util.get +import korlibs.* +import org.gradle.api.* +import org.gradle.process.* +import java.io.* +import java.net.* + +/** + * @NOTE: We have to call compileKotlinMingw first at least once so the toolchain is downloaded before doing stuff + */ +object WindowsToolchain { + val depsDir: File by lazy { File("${System.getProperty("user.home")}/.konan/dependencies").also { it.mkdirs() } } + val resourceHackerZip: File by lazy { + println("Downloading tool for replacing resources in executable..") + File(depsDir, "resource_hacker.zip").also { it.writeBytes(URL("https://github.com/korlibs/korge-tools/releases/download/resourcehacker/resource_hacker.zip").readBytes()) } + } + val resourceHackerDir: File by lazy { File(depsDir, "resourcehacker") } + val resourceHackerExe: File by lazy { + File(resourceHackerDir, "ResourceHacker.exe").also { + if (!it.exists()) { + resourceHackerDir.mkdirs() + unzipTo(resourceHackerDir, resourceHackerZip) + } + } + } + val msysDir by lazy { depsDir.getFirstRegexOrFail(Regex("^msys2-mingw")) } + val msys2 by lazy { depsDir.getFirstRegexOrNull(Regex("^msys2-mingw-w64-x86_64-clang")) } + val path by lazy { msysDir["bin"] } + val path2 by lazy { + msys2?.get("lib/gcc/x86_64-w64-mingw32")?.getFirstRegexOrFail(Regex("^\\d+\\.\\d+\\.\\d+$")) + } + val windres by lazy { path["windres.exe"] } + val strip by lazy { path["strip.exe"] } + //val rcOrNull by lazy { msys2?.get("bin/llvm-rc.exe") } + //val rc by lazy { rcOrNull ?: error("Can't find llvm-rc.exe") } +} + +fun Project.compileWindowsRC(rcFile: File, objFile: File, log: Boolean = true): File { + execThis { + commandLine(WindowsToolchain.windres.absolutePath, rcFile.path, "-O", "coff", objFile.absolutePath) + workingDir(rcFile.parentFile) + environment("PATH", System.getenv("PATH") + ";" + listOfNotNull(WindowsToolchain.path.absolutePath, WindowsToolchain.path2?.absolutePath).joinToString(";")) + if (log) { + logger.info("WindowsToolchain.path.absolutePath: ${WindowsToolchain.path.absolutePath}") + logger.info("WindowsToolchain.path2.absolutePath: ${WindowsToolchain.path2?.absolutePath}") + debugExecSpec(this) + } + } + return objFile +} + +fun Project.compileWindowsRES(rcFile: File, resFile: File, log: Boolean = true): File { + /* + execThis { + commandLine(WindowsToolchain.rc.absolutePath, rcFile.path) + workingDir(rcFile.parentFile) + environment("PATH", System.getenv("PATH") + ";" + listOfNotNull(WindowsToolchain.path.absolutePath, WindowsToolchain.path2?.absolutePath).joinToString(";")) + } + return File(rcFile.parentFile, "${rcFile.nameWithoutExtension}.res") + */ + execThis { + //rh.exe -open .\in\resources.rc -save .\out\resources.res -action compile -log NUL + workingDir = rcFile.parentFile + commandLineWindowsExe( + WindowsToolchain.resourceHackerExe.absolutePath, + "-open", rcFile.path, + "-save", resFile.path, + "-action", "compile", + //"-log", "NUL", + ) + } + return resFile +} + +fun ExecSpec.commandLineWindowsExe(vararg values: Any?): ExecSpec { + return this.commandLine( + *(if (!isWindows) arrayOf(WineHQ.EXEC) else emptyArray()), + *values + ) +} + +fun Project.replaceExeWithRes(exe: File, res: File) { + execThis { + commandLineWindowsExe( + WindowsToolchain.resourceHackerExe.absolutePath, + "-open", exe.path, + "-save", exe.path, + "-action", "addoverwrite", + "-res", res.absolutePath, + ) + } + +} + +fun Project.stripWindowsExe(exe: File, log: Boolean = true): File { + execThis { + commandLine(WindowsToolchain.strip.absolutePath, exe.absolutePath) + workingDir(exe.parentFile) + environment("PATH", System.getenv("PATH") + ";" + WindowsToolchain.path.absolutePath) + if (log) { + debugExecSpec(this) + } + } + return exe +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/texpacker/NewBinPacker.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/texpacker/NewBinPacker.kt new file mode 100644 index 0000000000..ceb9e0379a --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/texpacker/NewBinPacker.kt @@ -0,0 +1,1039 @@ +package korlibs.korge.gradle.texpacker + +import kotlin.math.* + +// Based on https://github.com/soimy/maxrects-packer/ +object NewBinPacker { + open class IBinPackerData { + var allowRotation: Boolean? = null + var tag: String? = null + } + + open class Rectangle( + width: Int = 0, + height: Int = 0, + x: Int = 0, + y: Int = 0, + rot: Boolean = false, + allowRotation: Boolean? = null, + val name: String? = null, + val raw: Any? = null, + ) { + val right: Int get() = x + width + val bottom: Int get() = y + height + + override fun toString(): String = "Rectangle($x, $y, $width, $height)[rot=$rot]" + + override fun hashCode(): Int { + return width * 1 + height * 3 + x * 7 + y * 11 + (if (rot) 1 else 0) + (if (allowRotation == true) 3333 else 0) + } + + var hash: Int? = null + + /** + * Oversized tag on rectangle which is bigger than packer itself. + * + * @type {boolean} + * @memberof Rectangle + */ + var oversized: Boolean = false + + + /** + * Get the area (w * h) of the rectangle + * + * @returns {number} + * @memberof Rectangle + */ + fun area(): Int { + return this.width * this.height; } + + /** + * Test if the given rectangle collide with this rectangle. + * + * @param {Rectangle} rect + * @returns {boolean} + * @memberof Rectangle + */ + fun collide(rect: Rectangle): Boolean { + return ( + rect.x < this.x + this.width && + rect.x + rect.width > this.x && + rect.y < this.y + this.height && + rect.y + rect.height > this.y + ) + } + + companion object { + /** + * Test if two given rectangle collide each other + * + * @static + * @param {Rectangle} first + * @param {Rectangle} second + * @returns + * @memberof Rectangle + */ + fun Collide(first: Rectangle, second: Rectangle): Boolean { + return first.collide(second); } + + /** + * Test if the first rectangle contains the second one + * + * @static + * @param {Rectangle} first + * @param {Rectangle} second + * @returns + * @memberof Rectangle + */ + fun Contain(first: Rectangle, second: Rectangle): Boolean { + return first.contain(second); } + + } + + /** + * Test if this rectangle contains the given rectangle. + * + * @param {Rectangle} rect + * @returns {boolean} + * @memberof Rectangle + */ + fun contain(rect: Rectangle): Boolean { + return (rect.x >= this.x && rect.y >= this.y && + rect.x + rect.width <= this.x + this.width && rect.y + rect.height <= this.y + this.height) + } + + var width: Int = width + set(value) { + if (field == value) return + field = value + _dirty++ + } + + var height: Int = height + set(value) { + if (field == value) return + field = value + _dirty++ + } + + var x: Int = x + set(value) { + if (field == value) return + field = value + _dirty++ + } + + var y: Int = y + set(value) { + if (field == value) return + field = value + _dirty++ + } + + protected var _rot: Boolean = rot + + /** + * If the rectangle is rotated + * + * @type {boolean} + * @memberof Rectangle + */ + var rot: Boolean + get() { + return this._rot; } + /** + * Set the rotate tag of the rectangle. + * + * note: after `rot` is set, `width/height` of this rectangle is swaped. + * + * @memberof Rectangle + */ + set(value) { + if (this._allowRotation == false) return + + if (this._rot != value) { + val tmp = this.width + this.width = this.height + this.height = tmp + this._rot = value + this._dirty++ + } + + } + + protected var _allowRotation: Boolean? = allowRotation + + /** + * If the rectangle allow rotation + * + * @type {boolean} + * @memberof Rectangle + */ + var allowRotation: Boolean? + get() { + return this._allowRotation; } + /** + * Set the allowRotation tag of the rectangle. + * + * @memberof Rectangle + */ + set(value) { + if (this._allowRotation !== value) { + this._allowRotation = value + this._dirty++ + } + } + + protected var _data: IBinPackerData? = null + + var data: IBinPackerData? + get() = this._data + set(value) { + if (value === null || value === this._data) return + this._data = value + // extract allowRotation settings + if (value.allowRotation != null) { + this._allowRotation = value.allowRotation + } + this._dirty++ + } + + protected var _dirty: Int = 0 + val dirty: Boolean get() = this._dirty > 0 + fun setDirty(value: Boolean = true): Unit { + this._dirty = if (value) this._dirty + 1 else 0 + } + + var tag: String? = null + } + + interface IBin { + val width: Int + val height: Int + val maxWidth: Int + val maxHeight: Int + val freeRects: MutableList + val rects: List + val options: IOption + var data: IBinPackerData? + var tag: String? + //[propName: String]: Any + } + + data class MBin( + override val width: Int, + override val height: Int, + override val maxWidth: Int, + override val maxHeight: Int, + override val freeRects: MutableList = arrayListOf(), + override val rects: List = listOf(), + override val options: IOption, + override var data: IBinPackerData? = null, + override var tag: String? = null, + ) : IBin + + + abstract class Bin : IBin { + abstract fun add(rect: Rectangle): Rectangle? + abstract fun add(width: Int, height: Int, data: IBinPackerData?): Rectangle? + abstract fun reset(deepReset: Boolean = false, resetOption: Boolean = false): Unit + abstract fun repack(): List? + + override var data: IBinPackerData? = null + override var tag: String? = null + + protected var _dirty: Int = 0 + + val dirty: Boolean get() = this._dirty > 0 || this.rects.any { it.dirty } + /** + * Set bin dirty status + * + * @memberof Bin + */ + fun setDirty(value: Boolean = true): Unit { + this._dirty = if (value) this._dirty + 1 else 0 + if (!value) { + for (rect in this.rects) { + rect.setDirty(false) + } + } + } + + abstract fun clone(): Bin + } + + class OversizedElementBin private constructor( + override var width: Int, + override var height: Int, + override var data: IBinPackerData? = null, + val dummy: Unit + ) : Bin() { + override val maxWidth: Int = width + override val maxHeight: Int = height + override var options: IOption = IOption(smart = false, pot = false, square = false) + override var rects: MutableList = arrayListOf() + override var freeRects: MutableList = arrayListOf() + + constructor(rect: Rectangle, data: IBinPackerData? = null) : this(rect.width, rect.height, data, dummy = Unit) { + rect.oversized = true + this.rects.add(rect) + } + + constructor (width: Int, height: Int, data: IBinPackerData?) : this(Rectangle(width, height), data) + + override fun add(rect: Rectangle): Rectangle? = null + override fun add(width: Int, height: Int, data: IBinPackerData?): Rectangle? = null + + override fun reset(deepReset: Boolean, resetOption: Boolean) { + // nothing to do here + } + + override fun repack(): List? { + return null; } + + override fun clone(): Bin { + val clonedBin: OversizedElementBin = OversizedElementBin(this.rects[0]) + return clonedBin + } + } + + class MaxRectsBin( + override val maxWidth: Int = EDGE_MAX_VALUE, + override val maxHeight: Int = EDGE_MAX_VALUE, + val padding: Int = 0, + override var options: IOption = IOption( + smart = true, + pot = true, + square = true, + allowRotation = false, + tag = false, + exclusiveTag = true, + border = 0, + logic = PACKING_LOGIC.MAX_EDGE + ) + ) : Bin() { + override var width: Int = if (this.options.smart) 0 else maxWidth + override var height: Int = if (this.options.smart) 0 else maxHeight + var border: Int = this.options.border + override var freeRects: MutableList = arrayListOf( + Rectangle( + this.maxWidth + this.padding - this.border * 2, + this.maxHeight + this.padding - this.border * 2, + this.border, + this.border + ) + ) + override var rects: MutableList = arrayListOf() + private var verticalExpand: Boolean = false + private var stage: Rectangle = Rectangle(this.width, this.height) + + override fun add(rect: Rectangle): Rectangle? { + // Check if rect.tag match bin.tag, if bin.tag not defined, it will accept any rect + val tag = rect.data?.tag ?: rect.tag + if (this.options.tag && this.options.exclusiveTag && this.tag !== tag) return null + val result = this.place(rect) + if (result != null) this.rects.add(result) + return result + } + + override fun add(width: Int, height: Int, data: IBinPackerData?): Rectangle? { + // Check if data.tag match bin.tag, if bin.tag not defined, it will accept any rect + if (this.options.tag && this.options.exclusiveTag) { + if (data != null && this.tag !== data.tag) return null + if (data == null && this.tag != null) return null + } + val rect = Rectangle(width, height) + rect.data = data + rect.setDirty(false) + val result = this.place(rect) + if (result != null) this.rects.add(result) + return result + } + + override fun repack(): List? { + val unpacked: MutableList = arrayListOf() + this.reset() + // re-sort rects from big to small + this.rects.sortWith(Comparator { a, b -> + val result = Math.max(b.width, b.height) - Math.max(a.width, a.height) + if (result == 0 && a.hash != null && b.hash != null) { + if (a.hash!! > b.hash!!) -1 else 1 + } else { + result + } + }) + for (rect in this.rects) { + if (this.place(rect) == null) { + unpacked.add(rect) + } + } + for (rect in unpacked) this.rects.removeAt(this.rects.indexOf(rect)) + return if (unpacked.size > 0) unpacked else null + } + + override fun reset(deepReset: Boolean, resetOption: Boolean): Unit { + if (deepReset) { + if (this.data != null) this.data = null + if (this.tag != null) this.tag = null + this.rects = arrayListOf() + if (resetOption) { + this.options = IOption( + smart = true, + pot = true, + square = true, + allowRotation = false, + tag = false, + border = 0 + ) + } + } + this.width = if (this.options.smart) 0 else this.maxWidth + this.height = if (this.options.smart) 0 else this.maxHeight + this.border = if (this.options.border != 0) this.options.border else 0 + this.freeRects = arrayListOf( + Rectangle( + this.maxWidth + this.padding - this.border * 2, + this.maxHeight + this.padding - this.border * 2, + this.border, + this.border + ) + ) + this.stage = Rectangle(this.width, this.height) + this._dirty = 0 + } + + override fun clone(): MaxRectsBin { + var clonedBin: MaxRectsBin = MaxRectsBin(this.maxWidth, this.maxHeight, this.padding, this.options) + for (rect in this.rects) { + clonedBin.add(rect) + } + return clonedBin + } + + private fun place(rect: Rectangle): Rectangle? { + // recheck if tag matched + var tag = rect.data?.tag ?: rect.tag + if (this.options.tag && this.options.exclusiveTag && this.tag !== tag) return null + + val node: Rectangle? + val allowRotation: Boolean = rect.allowRotation ?: this.options.allowRotation + node = this.findNode(rect.width + this.padding, rect.height + this.padding, allowRotation) + + if (node != null) { + this.updateBinSize(node) + var numRectToProcess = this.freeRects.size + var i: Int = 0 + while (i < numRectToProcess) { + if (this.splitNode(this.freeRects[i], node)) { + this.freeRects.removeAt(i) + numRectToProcess-- + i-- + } + i++ + } + this.pruneFreeList() + this.verticalExpand = this.width > this.height + rect.x = node.x + rect.y = node.y + if (rect.rot == null) rect.rot = false + rect.rot = if (node.rot) !rect.rot else rect.rot + this._dirty++ + return rect + } else if (!this.verticalExpand) { + if (this.updateBinSize( + Rectangle( + rect.width + this.padding, rect.height + this.padding, + this.width + this.padding - this.border, this.border + ) + ) || this.updateBinSize( + Rectangle( + rect.width + this.padding, rect.height + this.padding, + this.border, this.height + this.padding - this.border + ) + ) + ) { + return this.place(rect) + } + } else { + if (this.updateBinSize( + Rectangle( + rect.width + this.padding, rect.height + this.padding, + this.border, this.height + this.padding - this.border + ) + ) || this.updateBinSize( + Rectangle( + rect.width + this.padding, rect.height + this.padding, + this.width + this.padding - this.border, this.border + ) + ) + ) { + return this.place(rect) + } + } + return null + } + + private fun findNode(width: Int, height: Int, allowRotation: Boolean): Rectangle? { + var score: Int = Int.MAX_VALUE + var bestNode: Rectangle? = null + for (r in this.freeRects) { + if (r.width >= width && r.height >= height) { + val areaFit = when (this.options.logic) { + PACKING_LOGIC.MAX_AREA -> r.width * r.height - width * height + else -> Math.min(r.width - width, r.height - height) + } + if (areaFit < score) { + bestNode = Rectangle(width, height, r.x, r.y) + score = areaFit + } + } + + if (!allowRotation) continue + + // Continue to test 90-degree rotated rectangle + if (r.width >= height && r.height >= width) { + val areaFit = when (this.options.logic) { + PACKING_LOGIC.MAX_AREA -> r.width * r.height - height * width + else -> Math.min(r.height - width, r.width - height) + } + if (areaFit < score) { + bestNode = Rectangle(height, width, r.x, r.y, true) // Rotated node + score = areaFit + } + } + } + return bestNode + } + + private fun splitNode(freeRect: Rectangle, usedNode: Rectangle): Boolean { + // Test if usedNode intersect with freeRect + if (!freeRect.collide(usedNode)) return false + + // Do vertical split + if (usedNode.x < freeRect.x + freeRect.width && usedNode.x + usedNode.width > freeRect.x) { + // New node at the top side of the used node + if (usedNode.y > freeRect.y && usedNode.y < freeRect.y + freeRect.height) { + val newNode: Rectangle = Rectangle(freeRect.width, usedNode.y - freeRect.y, freeRect.x, freeRect.y) + this.freeRects.add(newNode) + } + // New node at the bottom side of the used node + if (usedNode.y + usedNode.height < freeRect.y + freeRect.height) { + val newNode = Rectangle( + freeRect.width, + freeRect.y + freeRect.height - (usedNode.y + usedNode.height), + freeRect.x, + usedNode.y + usedNode.height + ) + this.freeRects.add(newNode) + } + } + + // Do Horizontal split + if (usedNode.y < freeRect.y + freeRect.height && + usedNode.y + usedNode.height > freeRect.y + ) { + // New node at the left side of the used node. + if (usedNode.x > freeRect.x && usedNode.x < freeRect.x + freeRect.width) { + val newNode = Rectangle(usedNode.x - freeRect.x, freeRect.height, freeRect.x, freeRect.y) + this.freeRects.add(newNode) + } + // New node at the right side of the used node. + if (usedNode.x + usedNode.width < freeRect.x + freeRect.width) { + val newNode = Rectangle( + freeRect.x + freeRect.width - (usedNode.x + usedNode.width), + freeRect.height, + usedNode.x + usedNode.width, + freeRect.y + ) + this.freeRects.add(newNode) + } + } + return true + } + + private fun pruneFreeList() { + // Go through each pair of freeRects and remove any rects that is redundant + var i: Int = 0 + var j: Int = 0 + var len: Int = this.freeRects.size + while (i < len) { + j = i + 1 + var tmpRect1 = this.freeRects[i] + while (j < len) { + var tmpRect2 = this.freeRects[j] + if (tmpRect2.contain(tmpRect1)) { + this.freeRects.removeAt(i) + i-- + len-- + break + } + if (tmpRect1.contain(tmpRect2)) { + this.freeRects.removeAt(j) + j-- + len-- + } + j++ + } + i++ + } + } + + private fun updateBinSize(node: Rectangle): Boolean { + if (!this.options.smart) return false + if (this.stage.contain(node)) return false + var tmpWidth: Int = Math.max(this.width, node.x + node.width - this.padding + this.border) + var tmpHeight: Int = Math.max(this.height, node.y + node.height - this.padding + this.border) + //println("updateBinSize: $tmpWidth, $tmpHeight : $node") + if (this.options.allowRotation) { + // do extra test on rotated node whether it's a better choice + val rotWidth: Int = Math.max(this.width, node.x + node.height - this.padding + this.border) + val rotHeight: Int = Math.max(this.height, node.y + node.width - this.padding + this.border) + if (rotWidth * rotHeight < tmpWidth * tmpHeight) { + tmpWidth = rotWidth + tmpHeight = rotHeight + } + } + if (this.options.pot) { + tmpWidth = 2.0.pow(ceil(log2(tmpWidth.toDouble()))).toInt() + tmpHeight = 2.0.pow(ceil(log2(tmpHeight.toDouble()))).toInt() + //println("tmpWidth=$tmpWidth, tmpHeight=$tmpHeight") + } + if (this.options.square) { + val max = Math.max(tmpWidth, tmpHeight) + tmpWidth = max + tmpHeight = max + } + if (tmpWidth > this.maxWidth + this.padding || tmpHeight > this.maxHeight + this.padding) { + return false + } + this.expandFreeRects(tmpWidth + this.padding, tmpHeight + this.padding) + this.width = tmpWidth; this.stage.width = tmpWidth + this.height = tmpHeight; this.stage.height = tmpHeight + return true + } + + private fun expandFreeRects(width: Int, height: Int) { + for (freeRect in this.freeRects) { + if (freeRect.x + freeRect.width >= Math.min(this.width + this.padding - this.border, width)) { + freeRect.width = width - freeRect.x - this.border + } + if (freeRect.y + freeRect.height >= Math.min(this.height + this.padding - this.border, height)) { + freeRect.height = height - freeRect.y - this.border + } + } + this.freeRects.add( + Rectangle( + width - this.width - this.padding, + height - this.border * 2, + this.width + this.padding - this.border, + this.border + ) + ) + this.freeRects.add( + Rectangle( + width - this.border * 2, + height - this.height - this.padding, + this.border, + this.height + this.padding - this.border + ) + ) + this.freeRects = this.freeRects.filter { freeRect -> + !(freeRect.width <= 0 || freeRect.height <= 0 || freeRect.x < this.border || freeRect.y < this.border) + }.toMutableList() + this.pruneFreeList() + } + } + + const val EDGE_MAX_VALUE: Int = 4096 + const val EDGE_MIN_VALUE: Int = 128 + + enum class PACKING_LOGIC { MAX_AREA, MAX_EDGE } + + /** + * Options for MaxRect Packer + * + * @property {boolean} options.smart Smart sizing packer (default is true) + * @property {boolean} options.pot use power of 2 sizing (default is true) + * @property {boolean} options.square use square size (default is false) + * @property {boolean} options.allowRotation allow rotation packing (default is false) + * @property {boolean} options.tag allow auto grouping based on `rect.tag` (default is false) + * @property {boolean} options.exclusiveTag tagged rects will have dependent bin, if set to `false`, packer will try to put tag rects into the same bin (default is true) + * @property {boolean} options.border atlas edge spacing (default is 0) + * @property {PACKING_LOGIC} options.logic MAX_AREA or MAX_EDGE based sorting logic (default is MAX_EDGE) + * @export + * @interface Option + */ + data class IOption( + val smart: Boolean = true, + val pot: Boolean = true, + val square: Boolean = false, + val allowRotation: Boolean = false, + val tag: Boolean = false, + val exclusiveTag: Boolean = true, + val border: Int = 0, + val logic: PACKING_LOGIC = PACKING_LOGIC.MAX_EDGE, + ) + + /** + * Creates an instance of MaxRectsPacker. + * + * @param {number} width of the output atlas (default is 4096) + * @param {number} height of the output atlas (default is 4096) + * @param {number} padding between glyphs/images (default is 0) + * @param {IOption} [options={}] (Optional) packing options + * @memberof MaxRectsPacker + */ + class MaxRectsPacker( + val width: Int = EDGE_MAX_VALUE, + val height: Int = EDGE_MAX_VALUE, + val padding: Int = 0, + /** + * The Bin array added to the packer + * + * @type {Bin[]} + * @memberof MaxRectsPacker + */ + /** + * Options for MaxRect Packer + * + * @property {boolean} options.smart Smart sizing packer (default is true) + * @property {boolean} options.pot use power of 2 sizing (default is true) + * @property {boolean} options.square use square size (default is false) + * @property {boolean} options.allowRotation allow rotation packing (default is false) + * @property {boolean} options.tag allow auto grouping based on `rect.tag` (default is false) + * @property {boolean} options.exclusiveTag tagged rects will have dependent bin, if set to `false`, packer will try to put tag rects into the same bin (default is true) + * @property {boolean} options.border atlas edge spacing (default is 0) + * @property {PACKING_LOGIC} options.logic MAX_AREA or MAX_EDGE based sorting logic (default is MAX_EDGE) + * @export + * @interface Option + */ + var options: IOption = IOption( + smart = true, + pot = true, + square = false, + allowRotation = false, + tag = false, + exclusiveTag = true, + border = 0, + logic = PACKING_LOGIC.MAX_EDGE + ) + ) { + var bins = arrayListOf() + + /** + * Add a bin/rectangle object extends Rectangle to packer + * + * @template T Generic type extends Rectangle interface + * @param {T} rect the rect object add to the packer bin + * @memberof MaxRectsPacker + */ + fun add(rect: Rectangle): Rectangle { + if (rect.width > this.width || rect.height > this.height) { + this.bins.add(OversizedElementBin(rect)) + } else { + val added = this.bins.drop(this._currentBinIndex).firstOrNull { it.add(rect) != null } + if (added == null) { + val bin = MaxRectsBin(this.width, this.height, this.padding, this.options) + val tag = rect.data?.tag ?: rect.tag + if (this.options.tag && tag != null) bin.tag = tag + bin.add(rect) + this.bins.add(bin) + } + } + return rect + } + /** + * Add a bin/rectangle object with data to packer + * + * @param {number} width of the input bin/rectangle + * @param {number} height of the input bin/rectangle + * @param {*} data custom data object + * @memberof MaxRectsPacker + */ + fun add(width: Int, height: Int, data: IBinPackerData?): Any { + val rect = Rectangle(width, height) + rect.data = data + + if (rect.width > this.width || rect.height > this.height) { + this.bins.add(OversizedElementBin(rect)) + } else { + var added = this.bins.slice(this._currentBinIndex).firstOrNull { it.add(rect) != null } + if (added == null) { + var bin = MaxRectsBin(this.width, this.height, this.padding, this.options) + if (this.options.tag && rect.data?.tag != null) bin.tag = rect.data?.tag + bin.add(rect) + this.bins.add(bin) + } + } + return rect + } + + /** + * Add an Array of bins/rectangles to the packer. + * + * `Javascript`: Any object has property: { width, height, ... } is accepted. + * + * `Typescript`: object shall extends `MaxrectsPacker.Rectangle`. + * + * note: object has `hash` property will have more stable packing result + * + * @param {Rectangle[]} rects Array of bin/rectangles + * @memberof MaxRectsPacker + */ + fun addArray(rects: List) { + if (!this.options.tag || this.options.exclusiveTag) { + // if not using tag or using exclusiveTag, old approach + for (rect in this.sort(rects, this.options.logic)) { + this.add(rect) + } + } else { + // sort rects by tags first + if (rects.isEmpty()) return + val rects = rects.toMutableList() + rects.sortWith(Comparator { a, b -> + val aTag = if (a.data?.tag != null) a.data!!.tag else if (a.tag != null) a.tag else null + val bTag = if (b.data?.tag != null) b.data!!.tag else if (b.tag != null) b.tag else null + if (bTag === null) -1 else if (aTag === null) 1 else if (bTag > aTag) -1 else 1 + }) + + // iterate all bins to find the first bin which can place rects with same tag + // + var currentTag: String? = null + var currentIdx: Int = 0 + val targetBin = this.bins.slice(this._currentBinIndex).find { bin -> + val testBin = bin.clone() + for (i in currentIdx until rects.size) { + val rect = rects[i] + val tag = when { + rect.data != null && rect.data!!.tag != null -> rect.data?.tag + rect.tag != null -> rect.tag + else -> null + } + + // initialize currentTag + if (i == 0) currentTag = tag + + if (tag != currentTag) { + // all current tag memeber tested successfully + currentTag = tag + // do addArray() + for (r in this.sort(rects.slice(currentIdx, i), this.options.logic)) { + bin.add(r) + } + currentIdx = i + + // recrusively addArray() with remaining rects + this.addArray(rects.drop(i)) + return@find true + } + + // remaining untagged rect will use normal addArray() + if (tag == null) { + // do addArray() + for (r in this.sort(rects.drop(i), this.options.logic)) { + this.add(r) + } + currentIdx = rects.size + // end test + return@find true + } + + // still in the same tag group + if (testBin.add(rect) === null) { + // add the rects that could fit into the bins already + // do addArray() + for (r in this.sort(rects.slice(currentIdx, i), this.options.logic)) { + bin.add(r) + } + currentIdx = i + + // current bin cannot contain all tag members + // procceed to test next bin + return@find false + } + } + + // all rects tested + // do addArray() to the remaining tag group + for (r in this.sort(rects.drop(currentIdx), this.options.logic)) bin.add(r) + return@find true + } + + // create a bin if no current bin fit + if (targetBin == null) { + val rect = rects[currentIdx] + val bin = MaxRectsBin(this.width, this.height, this.padding, this.options) + val tag = rect.data?.tag ?: rect.tag + if (this.options.tag && this.options.exclusiveTag && tag != null) bin.tag = tag + this.bins.add(bin) + // Add the rect to the newly created bin + bin.add(rect) + currentIdx++ + this.addArray(rects.drop(currentIdx)) + } + } + } + + /** + * Reset entire packer to initial states, keep settings + * + * @memberof MaxRectsPacker + */ + fun reset(): Unit { + this.bins = arrayListOf() + this._currentBinIndex = 0 + } + + /** + * Repack all elements inside bins + * + * @param {boolean} [quick=true] quick repack only dirty bins + * @returns {void} + * @memberof MaxRectsPacker + */ + fun repack(quick: Boolean = true): Unit { + if (quick) { + var unpack: MutableList = arrayListOf() + for (bin in this.bins) { + if (bin.dirty) { + var up = bin.repack() + if (up != null) unpack.addAll(up) + } + } + this.addArray(unpack) + return + } + if (!this.dirty) return + val allRects = this.rects + this.reset() + this.addArray(allRects) + } + + /** + * Stop adding element to the current bin and return a bin. + * + * note: After calling `next()` all elements will no longer added to previous bins. + * + * @returns {Bin} + * @memberof MaxRectsPacker + */ + fun next(): Int { + this._currentBinIndex = this.bins.size + return this._currentBinIndex + } + + /** + * Load bins to the packer, overwrite exist bins + * + * @param {MaxRectsBin[]} bins MaxRectsBin objects + * @memberof MaxRectsPacker + */ + fun load(bins: List) { + for ((index, bin) in bins.withIndex()) { + if (bin.maxWidth > this.width || bin.maxHeight > this.height) { + this.bins.add(OversizedElementBin(bin.width, bin.height, null)) + } else { + val newBin = MaxRectsBin(this.width, this.height, this.padding, bin.options) + newBin.freeRects.clear() + for (r in bin.freeRects) { + newBin.freeRects.add(Rectangle(r.width, r.height, r.x, r.y)) + } + newBin.width = bin.width + newBin.height = bin.height + if (bin.tag != null) newBin.tag = bin.tag + this.bins[index] = newBin + } + } + } + + /** + * Output current bins to save + * + * @memberof MaxRectsPacker + */ + fun save(): List { + val saveBins: MutableList = arrayListOf() + for (bin in this.bins) { + val saveBin: IBin = MBin( + width = bin.width, + height = bin.height, + maxWidth = bin.maxWidth, + maxHeight = bin.maxHeight, + freeRects = arrayListOf(), + rects = arrayListOf(), + options = bin.options, + tag = bin.tag + ) + for (r in bin.freeRects) { + saveBin.freeRects.add( + Rectangle( + x = r.x, + y = r.y, + width = r.width, + height = r.height + ) + ) + } + saveBins.add(saveBin) + } + return saveBins + } + + /** + * Sort the given rects based on longest edge or surface area. + * + * If rects have the same sort value, will sort by second key `hash` if presented. + * + * @private + * @param {List} rects + * @param {PACKING_LOGIC} [logic=PACKING_LOGIC.MAX_EDGE] sorting logic, "area" or "edge" + * @returns + * @memberof MaxRectsPacker + */ + private fun sort(rects: List, logic: PACKING_LOGIC = PACKING_LOGIC.MAX_EDGE): List { + return rects.toList().sortedWith(Comparator { a, b -> + val result = when { + logic === PACKING_LOGIC.MAX_EDGE -> Math.max(b.width, b.height) - Math.max(a.width, a.height) + else -> b.width * b.height - a.width * a.height + } + if (result == 0 && a.hash != null && b.hash != null) { + if (a.hash!! > b.hash!!) -1 else 1 + } else { + result + } + }) + } + + private var _currentBinIndex: Int = 0 + /** + * Return current functioning bin index, perior to this wont accept any elements + * + * @readonly + * @type {number} + * @memberof MaxRectsPacker + */ + val currentBinIndex: Int get() = this._currentBinIndex + + /** + * Returns dirty status of all child bins + * + * @readonly + * @type {boolean} + * @memberof MaxRectsPacker + */ + val dirty: Boolean get() = this.bins.any { it.dirty } + + /** + * Return all rectangles in this packer + * + * @readonly + * @type {List} + * @memberof MaxRectsPacker + */ + val rects: List get() = this.bins.flatMap { it.rects } + } + + private fun List.slice(index: Int): List = drop(index) + private fun List.slice(start: Int, end: Int): List = slice(start until end) +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/texpacker/NewTexturePacker.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/texpacker/NewTexturePacker.kt new file mode 100644 index 0000000000..df7ca2e8f9 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/texpacker/NewTexturePacker.kt @@ -0,0 +1,141 @@ +package korlibs.korge.gradle.texpacker + +import com.android.build.gradle.internal.cxx.json.* +import korlibs.korge.gradle.* +import java.awt.* +import java.io.* + +object NewTexturePacker { + data class Info( + val file: File, + val fullArea: Rectangle, + val trimArea: Rectangle, + val trimmedImage: SimpleBitmap, + ) + + data class AtlasInfo( + val image: SimpleBitmap, + val info: Map + ) { + fun write(output: File): File { + val imageOut = File(output.parentFile, output.nameWithoutExtension + ".png") + (info["meta"] as MutableMap)["image"] = imageOut.name + output.writeText(jsonStringOf(info)) + image.writeTo(imageOut) + return imageOut + } + } + + data class FileWithBase(val base: File, val file: File) { + val relative = file.relativeTo(base) + } + + fun getAllFiles(vararg folders: File): List { + return folders.flatMap { base -> base.walk().mapNotNull { + when { + it.name.startsWith('.') -> null + it.isFile -> FileWithBase(base, it) + else -> null + } + } } + } + + fun packImages( + vararg folders: File, + enableRotation: Boolean = true, + enableTrimming: Boolean = true, + ): List { + val images: List> = getAllFiles(*folders).mapNotNull { + try { + it.relative to SimpleBitmap(it.file) + } catch (e: Throwable) { + e.printStackTrace() + null + } + } + + val PADDING = 2 + + val packer = NewBinPacker.MaxRectsPacker(4096, 4096, PADDING * 2, NewBinPacker.IOption( + smart = true, + pot = true, + square = false, + allowRotation = enableRotation, + //allowRotation = false, + tag = false, + border = PADDING + )) + + packer.addArray(images.map { (file, image) -> + val fullArea = Rectangle(0, 0, image.width, image.height) + val trimArea = if (enableTrimming) image.trim() else fullArea + val trimmedImage = image.slice(trimArea) + //println(trimArea == fullArea) + NewBinPacker.Rectangle(width = trimmedImage.width, height = trimmedImage.height, raw = Info( + file, fullArea, trimArea, trimmedImage + )) + }) + + val outAtlases = arrayListOf() + for (bin in packer.bins) { + //val rwidth = bin.rects.maxOf { it.right } + //val rheight = bin.rects.maxOf { it.bottom } + //val maxWidth = bin.maxWidth + //val maxHeight = bin.maxHeight + //val out = SimpleBitmap(rwidth, rheight) + val out = SimpleBitmap(bin.width, bin.height) + //println("${bin.width}x${bin.height}") + + val frames = linkedMapOf() + + for (rect in bin.rects) { + val info = rect.raw as Info + val fileName = info.file.name + //println("$rect :: info=$info") + + val chunk = if (rect.rot) info.trimmedImage.flipY().rotate90() else info.trimmedImage + out.put(rect.x - PADDING, rect.y - PADDING, chunk.extrude(PADDING)) + //out.put(rect.x, rect.y, chunk) + + val obj = LinkedHashMap() + + fun Dimension.toObj(rot: Boolean): Map { + val w = if (!rot) width else height + val h = if (!rot) height else width + return mapOf("w" to w, "h" to h) + } + fun Rectangle.toObj(rot: Boolean): Map { + return mapOf("x" to x, "y" to y) + this.size.toObj(rot) + } + + obj["frame"] = Rectangle(rect.x, rect.y, rect.width, rect.height).toObj(rect.rot) + obj["rotated"] = rect.rot + obj["trimmed"] = info.trimArea != info.fullArea + obj["spriteSourceSize"] = info.trimArea.toObj(false) + obj["sourceSize"] = info.fullArea.size.toObj(false) + + frames[fileName] = obj + } + + + val atlasOut = linkedMapOf( + "frames" to frames, + "meta" to mapOf( + "app" to "https://korge.org/", + "version" to BuildVersions.KORGE, + "image" to "", + "format" to "RGBA8888", + "size" to mapOf( + "w" to bin.width, + "h" to bin.height + ), + "scale" to 1 + ), + ) + + outAtlases.add(AtlasInfo(out, atlasOut)) + } + + return outAtlases + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/texpacker/SimpleBitmap.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/texpacker/SimpleBitmap.kt new file mode 100644 index 0000000000..ac008a534f --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/texpacker/SimpleBitmap.kt @@ -0,0 +1,142 @@ +package korlibs.korge.gradle.texpacker + +import java.awt.* +import java.awt.image.* +import java.io.* +import javax.imageio.* +import kotlin.math.* + +inline class SimpleRGBA(val data: Int) { + val r: Int get() = (data ushr 0) and 0xFF + val g: Int get() = (data ushr 8) and 0xFF + val b: Int get() = (data ushr 16) and 0xFF + val a: Int get() = (data ushr 24) and 0xFF +} + +class SimpleBitmap(val width: Int, val height: Int, val data: IntArray = IntArray(width * height)) { + override fun toString(): String = "SimpleBitmap($width, $height)" + + companion object { + operator fun invoke(image: BufferedImage): SimpleBitmap { + val width = image.width + val height = image.height + val out = IntArray(width * height) + image.getRGB(0, 0, width, height, out, 0, width) + return SimpleBitmap(width, height, out) + } + operator fun invoke(file: File): SimpleBitmap { + return invoke(ImageIO.read(file) ?: error("Couldn't read $file as an image")) + } + } + private fun index(x: Int, y: Int): Int = y * width + x + operator fun get(x: Int, y: Int): SimpleRGBA = SimpleRGBA(data[index(x, y)]) + operator fun set(x: Int, y: Int, value: SimpleRGBA) { data[index(x, y)] = value.data } + + fun toBufferedImage(): BufferedImage = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB).also { + it.setRGB(0, 0, width, height, this.data, 0, width) + } + + fun writeTo(file: File) { + ImageIO.write(toBufferedImage(), file.extension, file) + } + + fun put(px: Int, py: Int, other: SimpleBitmap) { + //println("put $other in $this at [$px, $py]") + for (y in 0 until other.height) { + System.arraycopy(other.data, other.index(0, y), this.data, this.index(px, py + y), other.width) + } + } + + fun slice(rect: Rectangle): SimpleBitmap { + val out = SimpleBitmap(rect.width, rect.height) + for (y in 0 until rect.height) { + System.arraycopy(this.data, this.index(rect.x, rect.y + y), out.data, out.index(0, y), rect.width) + } + return out + } + + fun trim(): Rectangle { + var minLeft = width + var minRight = width + var minTop = height + var minBottom = height + for (y in 0 until height) { + for (x in 0 until width) if (this[x, y].a != 0) { minLeft = min(minLeft, x); break } + for (x in 0 until width) if (this[width - x - 1, y].a != 0) { minRight = min(minRight, x); break } + } + for (x in 0 until width) { + for (y in 0 until height) if (this[x, y].a != 0) { minTop = min(minTop, y); break } + for (y in 0 until height) if (this[x, height - y - 1].a != 0) { minBottom = min(minBottom, y); break } + } + if (minLeft == width || minTop == height) { + return Rectangle(0, 0, 0, 0) + } + return Rectangle(minLeft, minTop, width - minRight - minLeft, height - minBottom - minTop) + } + + fun transferRect(x: Int, y: Int, width: Int, height: Int, out: IntArray, write: Boolean) { + for (n in 0 until height) { + val tindex = this.index(x, y + n) + val oindex = width * n + when { + write -> System.arraycopy(out, oindex, this.data, tindex, width) + else -> System.arraycopy(this.data, tindex, out, oindex, width) + } + } + } + fun getRect(x: Int, y: Int, width: Int, height: Int, out: IntArray = IntArray(width * height)): IntArray { + transferRect(x, y, width, height, out, write = false) + return out + } + fun putRect(x: Int, y: Int, width: Int, height: Int, out: IntArray) { + transferRect(x, y, width, height, out, write = true) + } + + fun flipY(): SimpleBitmap { + val out = SimpleBitmap(width, height) + val row = IntArray(width) + for (y in 0 until height) { + getRect(0, y, width, 1, row) + out.putRect(0, height - 1 - y, width, 1, row) + } + return out + } + + fun rotate90(): SimpleBitmap { + val out = SimpleBitmap(height, width) + val row = IntArray(width) + for (y in 0 until height) { + getRect(0, y, width, 1, row) + out.putRect(y, 0, 1, width, row) + } + return out + } + + fun extrude(border: Int): SimpleBitmap { + val nwidth = width + border * 2 + val nheight = height + border * 2 + val out = SimpleBitmap(nwidth, nheight) + out.put(border, border, this) + // left + run { + val part = out.slice(Rectangle(border, 0, 1, nheight)) + for (n in 0 until border) out.put(n, 0, part) + } + // right + run { + val part = out.slice(Rectangle(nwidth - border - 1, 0, 1, nheight)) + for (n in 0 until border) out.put(nwidth - n - 1, 0, part) + } + // top + run { + val part = out.slice(Rectangle(0, border, nwidth, 1)) + for (n in 0 until border) out.put(0, n, part) + } + // bottom + run { + val part = out.slice(Rectangle(0, nheight - border - 1, nwidth, 1)) + for (n in 0 until border) out.put(0, nheight - n - 1, part) + } + return out + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/typedresources/TypedResourcesGenerator.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/typedresources/TypedResourcesGenerator.kt new file mode 100644 index 0000000000..58b02d7667 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/typedresources/TypedResourcesGenerator.kt @@ -0,0 +1,238 @@ +package korlibs.korge.gradle.typedresources + +import korlibs.* +import korlibs.korge.gradle.* +import korlibs.korge.gradle.kotlin +import korlibs.korge.gradle.util.* +import korlibs.korge.gradle.util.ensureParents +import org.gradle.api.* +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.* +import java.io.* + +class TypedResourcesGenerator { + companion object { + val STARTS_WITH_NUMBER = Regex("^\\d") + val REGEX_NON_WORDS = Regex("\\W+") + } + fun String.normalizeName(): String { + val res = this.replace(REGEX_NON_WORDS, "_").trim('_') + return if (STARTS_WITH_NUMBER.matchesAt(res, 0)) "n$res" else res + } + + fun String.nameToVariable(): String { + return normalizeName().textCase().camelCase() + } + + fun generateForFolders(resourcesFolder: SFile, reporter: (e: Throwable, message: String) -> Unit = { e, message -> + System.err.println(message) + e.printStackTrace() + }): String { + return Indenter { + line("import korlibs.audio.sound.*") + line("import korlibs.io.file.*") + line("import korlibs.io.file.std.*") + line("import korlibs.image.bitmap.*") + line("import korlibs.image.atlas.*") + line("import korlibs.image.font.*") + line("import korlibs.image.format.*") + line("") + line("// AUTO-GENERATED FILE! DO NOT MODIFY!") + line("") + line("@Retention(AnnotationRetention.BINARY) annotation class ResourceVfsPath(val path: String)") + line("inline class TypedVfsFile(val __file: VfsFile)") + line("inline class TypedVfsFileTTF(val __file: VfsFile) {") + line(" suspend fun read(): korlibs.image.font.TtfFont = this.__file.readTtfFont()") + line("}") + line("inline class TypedVfsFileBitmap(val __file: VfsFile) {") + line(" suspend fun read(): korlibs.image.bitmap.Bitmap = this.__file.readBitmap()") + line(" suspend fun readSlice(atlas: MutableAtlasUnit? = null, name: String? = null): BmpSlice = this.__file.readBitmapSlice(name, atlas)") + line("}") + line("inline class TypedVfsFileSound(val __file: VfsFile) {") + line(" suspend fun read(): korlibs.audio.sound.Sound = this.__file.readSound()") + line("}") + line("interface TypedAtlas") + + data class ExtraInfo(val file: SFile, val className: String) + + val atlases = arrayListOf() + val ases = arrayListOf() + + val exploredFolders = LinkedHashSet() + val foldersToExplore = ArrayDeque() + foldersToExplore += resourcesFolder + + line("") + line("object KR : __KR.KR") + line("") + + line("object __KR") { + while (foldersToExplore.isNotEmpty()) { + val folder = foldersToExplore.removeFirst() + if (folder in exploredFolders) continue + exploredFolders += folder + val files = folder.list() + line("") + val classSuffix = folder.path.textCase().pascalCase() + line("${if (classSuffix.isEmpty()) "interface" else "object"} KR$classSuffix") { + line("val __file get() = resourcesVfs[${folder.path.quoted}]") + for (file in files.sortedBy { it.name } + .distinctBy { it.nameWithoutExtension.nameToVariable() }) { + if (file.path == "") continue + if (file.name.startsWith(".")) continue + val path = file.path + if (path.isEmpty()) continue + val varName = file.nameWithoutExtension.nameToVariable() + val fullVarName = file.path.normalizeName() + val extension = File(path).extension.lowercase() + //println("extension=$extension") + var extraSuffix = "" + val isDirectory = file.isDirectory() + val type: String? = when (extension) { + "png", "jpg" -> "TypedVfsFileBitmap" + "mp3", "wav" -> "TypedVfsFileSound" + "ttf", "otf" -> "TypedVfsFileTTF" + "ase" -> { + val className = "Ase${fullVarName.textCase().pascalCase()}" + ases += ExtraInfo(file, className) + "$className.TypedAse" + } + "atlas" -> { + if (isDirectory) { + extraSuffix += ".json" + val className = "Atlas${fullVarName.textCase().pascalCase()}" + atlases += ExtraInfo(file, className) + "$className.TypedAtlas" + } else { + "TypedVfsFile" + } + } + + else -> { + if (isDirectory) { + foldersToExplore += file + null + } else { + "TypedVfsFile" + } + } + } + val pathWithSuffix = "$path$extraSuffix" + val annotation = "@ResourceVfsPath(${pathWithSuffix.quoted})" + when { + type != null -> line("$annotation val `$varName` get() = $type(resourcesVfs[${pathWithSuffix.quoted}])") + else -> line("$annotation val `$varName` get() = __KR.KR${file.path.textCase().pascalCase()}") + } + } + } + } + } + + for (atlas in atlases) { + line("") + line("inline class ${atlas.className}(val __atlas: korlibs.image.atlas.Atlas)") { + line("inline class TypedAtlas(val __file: VfsFile) { suspend fun read(): ${atlas.className} = ${atlas.className}(this.__file.readAtlas()) }") + val atlasBaseDir = atlas.file + for (file in atlasBaseDir.list().sortedBy { it.name }) { + if (file.name.startsWith(".")) continue + if (file.isDirectory()) continue + val pathDir = file.name + if (pathDir.toString().isEmpty()) continue + line("@ResourceVfsPath(${file.path.quoted}) val `${file.nameWithoutExtension.normalizeName()}` get() = __atlas[${pathDir.quoted}]") + } + } + } + + for (ase in ases) { + val aseFile = ase.file + + val info = try { + ASEInfo.getAseInfo(ase.file.readBytes()) + } catch (e: Throwable) { + reporter(e, "ERROR LOADING FILE: aseFile=$aseFile") + ASEInfo() + } + + line("") + line("inline class ${ase.className}(val data: korlibs.image.format.ImageDataContainer)") { + line("inline class TypedAse(val __file: VfsFile) { suspend fun read(atlas: korlibs.image.atlas.MutableAtlasUnit? = null): ${ase.className} = ${ase.className}(this.__file.readImageDataContainer(korlibs.image.format.ASE.toProps(), atlas)) }") + + line("enum class TypedAnimation(val animationName: String)") { + for (tag in info.tags) { + line("${tag.tagName.nameToVariable().uppercase()}(${tag.tagName.quoted}),") + } + line(";") + line("companion object") { + line("val list: List = values().toList()") + for (tag in info.tags) { + line("val ${tag.tagName.nameToVariable().lowercase()}: TypedAnimation get() = TypedAnimation.${tag.tagName.nameToVariable().uppercase()}") + } + } + } + + line("inline class TypedImageData(val data: ImageData)") { + line("val animations: TypedAnimation.Companion get() = TypedAnimation") + } + + val uniqueNames = UniqueNameGenerator() + uniqueNames["animations"] // reserve names + uniqueNames["default"] // reserve names + + line("val animations: TypedAnimation.Companion get() = TypedAnimation") + line("val default: TypedImageData get() = TypedImageData(data.default)") + for (sliceName in info.slices.map { it.sliceName }.distinct()) { + val varName = uniqueNames[sliceName.nameToVariable()] + line("val `$varName`: TypedImageData get() = TypedImageData(data[${sliceName.quoted}]!!)") + } + // @TODO: We could + + //println("wizardFemale=${wizardFemale.imageDatasByName.keys}") + //println("wizardFemale.animations=${wizardFemale.imageDatas.first().animationsByName.keys}") + } + } + } + } +} + +open class GenerateTypedResourcesTask : DefaultTask() { + @get:OutputDirectory + var krDir: File = project.krDir + + @get:InputDirectory + var resourceFolders: FileCollection = project.resourceFileCollection + + @TaskAction + fun run() { + generateTypedResources(krDir, resourceFolders.toList()) + } +} + +private val Project.krDir: File get() = File(project.buildDir, "KR") +private val Project.resourceFileCollection: FileCollection get() = project.files( + "resources", + "src/commonMain/resources", +) + +private fun generateTypedResources(krDir: File, resourcesFolders: List) { + val file = File(krDir, "KR.kt").ensureParents() + // @TODO: Multiple resourcesFolders. Combine in a single File as a Merged File System for simplicity + val generatedText = TypedResourcesGenerator().generateForFolders(LocalSFile(resourcesFolders.first())) + if (!file.exists() || file.readText() != generatedText) { + file.writeText(generatedText) + } + file.writeText(generatedText) +} + +fun Project.configureTypedResourcesGenerator() { + val generateTypedResources = tasks.createTyped("generateTypedResources") + afterEvaluate { + if (project.korge.autoGenerateTypedResources) { + tasks.getByName("idea").dependsOn(generateTypedResources) + tasks.withType(KorgeGenerateResourcesTask::class.java).forEach { + it.finalizedBy(generateTypedResources) + } + kotlin.metadata().compilations.main.defaultSourceSet.kotlin.srcDir(generateTypedResources.krDir) + generateTypedResources(krDir, resourceFileCollection.toList()) + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/ASEInfo.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/ASEInfo.kt new file mode 100644 index 0000000000..66faebd661 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/ASEInfo.kt @@ -0,0 +1,129 @@ +package korlibs.korge.gradle.util + +data class ASEInfo( + val slices: List = emptyList(), + val tags: List = emptyList(), +) { + data class AseSlice( + val sliceName: String, + val hasNinePatch: Boolean, + val hasPivotInfo: Boolean, + ) + + data class AseTag( + val fromFrame: Int, + val toFrame: Int, + val direction: Int, + val tagColor: Int, + val tagName: String + ) + + companion object { + fun getAseInfo(file: SFile): ASEInfo { + return getAseInfo(file.readBytes()) + } + + fun getAseInfo(data: ByteArray): ASEInfo { + return getAseInfo(ByteArraySimpleInputStream(ByteArraySlice(data))) + } + + fun getAseInfo(s: ByteArraySimpleInputStream): ASEInfo { + if (s.length == 0) return ASEInfo() + + val slices = arrayListOf() + val tags = arrayListOf() + + val fileSize = s.readS32LE() + if (s.length < fileSize) error("File too short s.length=${s.length} < fileSize=${fileSize}") + val headerMagic = s.readU16LE() + if (headerMagic != 0xA5E0) error("Not an Aseprite file : headerMagic=$headerMagic") + val numFrames = s.readU16LE() + val imageWidth = s.readU16LE() + val imageHeight = s.readU16LE() + val bitsPerPixel = s.readU16LE() + val bytesPerPixel = bitsPerPixel / 8 + val flags = s.readU32LE() + val speed = s.readU16LE() + s.skip(4) + s.skip(4) + val transparentIndex = s.readU8() + s.skip(3) + val numColors = s.readU16LE() + val pixelWidth = s.readU8() + val pixelHeight = s.readU8() + val gridX = s.readS16LE() + val gridY = s.readS16LE() + val gridWidth = s.readU16LE() + val gridHeight = s.readU16LE() + s.skip(84) + + //println("ASE fileSize=$fileSize, headerMagic=$headerMagic, numFrames=$numFrames, $imageWidth x $imageHeight, bitsPerPixel=$bitsPerPixel, numColors=$numColors, gridWidth=$gridWidth, gridHeight=$gridHeight") + + for (frameIndex in 0 until numFrames) { + //println("FRAME: $frameIndex") + val bytesInFrame = s.readS32LE() + val fs = s.readStream(bytesInFrame - 4) + val frameMagic = fs.readU16LE() + //println(" bytesInFrame=$bytesInFrame, frameMagic=$frameMagic, fs=$fs") + if (frameMagic != 0xF1FA) error("Invalid ASE sprite file or error parsing : frameMagic=$frameMagic") + fs.readU16LE() + val frameDuration = fs.readU16LE() + fs.skip(2) + val numChunks = fs.readS32LE() + + //println(" - $numChunks") + + for (nc in 0 until numChunks) { + val chunkSize = fs.readS32LE() + val chunkType = fs.readU16LE() + val cs = fs.readStream(chunkSize - 6) + + //println(" chunkType=$chunkType, chunkSize=$chunkSize") + + when (chunkType) { + 0x2022 -> { // SLICE KEYS + val numSliceKeys = cs.readS32LE() + val sliceFlags = cs.readS32LE() + cs.skip(4) + val sliceName = cs.readAseString() + val hasNinePatch = sliceFlags.hasBitSet(0) + val hasPivotInfo = sliceFlags.hasBitSet(1) + val aslice = AseSlice(sliceName, hasNinePatch, hasPivotInfo) + slices += aslice + } + 0x2018 -> { // TAGS + // Tags + val numTags = cs.readU16LE() + cs.skip(8) + //println(" tags: numTags=$numTags") + + for (tag in 0 until numTags) { + val fromFrame = cs.readU16LE() + val toFrame = cs.readU16LE() + val direction = cs.readU8() + cs.skip(8) + val tagColor = cs.readS32LE() + val tagName = cs.readAseString() + val atag = AseTag(fromFrame, toFrame, direction, tagColor, tagName) + tags += atag + //println(" tag[$tag]=$atag") + } + } + // Unsupported tag + else -> { + + } + } + } + } + + return ASEInfo( + slices = slices, + tags = tags, + ) + } + + fun ByteArraySimpleInputStream.readAseString(): String = readBytes(readU16LE()).toString(Charsets.UTF_8) + public infix fun Int.hasBitSet(index: Int): Boolean = ((this ushr index) and 1) != 0 + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/AnsiEscape.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/AnsiEscape.kt new file mode 100644 index 0000000000..3a751e3091 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/AnsiEscape.kt @@ -0,0 +1,56 @@ +package korlibs.korge.gradle.util + +// https://www.lihaoyi.com/post/BuildyourownCommandLinewithANSIescapecodes.html +interface AnsiEscape { + companion object : AnsiEscape { + inline operator fun invoke(block: AnsiEscape.() -> T): T = block() + } + + fun StringBuilder.appendAnsiScape(code: Int, extra: String? = null, char: Char = 'm'): StringBuilder { + append('\u001B') + append('[') + append(code) + if (extra != null) append(extra) + append(char) + return this + } + + enum class Color(val index: Int) { BLACK(0), RED(1), GREEN(2), YELLOW(3), BLUE(4), PURPLE(5), CYAN(6), WHITE(7) } + + fun StringBuilder.appendReset() = appendAnsiScape(0) + fun StringBuilder.appendBold() = appendAnsiScape(1) + fun StringBuilder.appendUnderline() = appendAnsiScape(4) + fun StringBuilder.appendColorReversed() = appendAnsiScape(7) + fun StringBuilder.appendFgColor(color: Color, bright: Boolean = false) = appendAnsiScape(30 + color.index, extra = if (bright) ";1" else null) + fun StringBuilder.appendBgColor(color: Color, bright: Boolean = false) = appendAnsiScape(40 + color.index, extra = if (bright) ";1" else null) + fun StringBuilder.appendMoveUp(n: Int = 1) = appendAnsiScape(n, char = 'A') + fun StringBuilder.appendMoveDown(n: Int = 1) = appendAnsiScape(n, char = 'B') + fun StringBuilder.appendMoveRight(n: Int = 1) = appendAnsiScape(n, char = 'C') + fun StringBuilder.appendMoveLeft(n: Int = 1) = appendAnsiScape(n, char = 'D') + + fun String.color(color: Color, bright: Boolean = false) = buildString { appendFgColor(color, bright).append(this@color).appendReset() } + fun String.bgColor(color: Color, bright: Boolean = false) = buildString { appendBgColor(color, bright).append(this@bgColor).appendReset() } + fun String.ansiEscape(code: Int) = buildString { appendAnsiScape(code).append(this@ansiEscape).appendReset() } + + val String.bold get() = ansiEscape(1) + val String.underline get() = ansiEscape(4) + val String.colorReversed get() = ansiEscape(7) + + val String.black get() = color(Color.BLACK) + val String.red get() = color(Color.RED) + val String.green get() = color(Color.GREEN) + val String.yellow get() = color(Color.YELLOW) + val String.blue get() = color(Color.BLUE) + val String.purple get() = color(Color.PURPLE) + val String.cyan get() = color(Color.CYAN) + val String.white get() = color(Color.WHITE) + + val String.bgBlack get() = bgColor(Color.BLACK) + val String.bgRed get() = bgColor(Color.RED) + val String.bgGreen get() = bgColor(Color.GREEN) + val String.bgYellow get() = bgColor(Color.YELLOW) + val String.bgBlue get() = bgColor(Color.BLUE) + val String.bgPurple get() = bgColor(Color.PURPLE) + val String.bgCyan get() = bgColor(Color.CYAN) + val String.bgWhite get() = bgColor(Color.WHITE) +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/DslExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/DslExt.kt new file mode 100644 index 0000000000..cec2d68d15 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/DslExt.kt @@ -0,0 +1,36 @@ +package korlibs.korge.gradle.util + +import korlibs.* +import kotlinx.kover.api.* +import org.gradle.api.* +import org.gradle.api.plugins.* +import org.gradle.api.tasks.* +import kotlin.reflect.* + +fun ExtensionContainer.getByName(name: String): T = getByName(name) as T + +inline fun TaskContainer.createThis(name: String, vararg params: Any, block: T.() -> Unit = {}): T { + return create(name, T::class.java, *params).apply(block) +} + +fun org.gradle.api.Project.`koverMerged`(configure: Action): Unit = (this as org.gradle.api.plugins.ExtensionAware).extensions.configure("koverMerged", configure) +fun org.gradle.api.Project.`kover`(configure: Action): Unit = (this as org.gradle.api.plugins.ExtensionAware).extensions.configure("kover", configure) +//fun TaskContainer.`dokkaHtml`(configure: org.jetbrains.dokka.gradle.DokkaTask.() -> Unit) { +// configure(named("dokkaHtml").get()) +//} + +/** + * Retrieves the [ext][org.gradle.api.plugins.ExtraPropertiesExtension] extension. + */ +val org.gradle.api.Project._ext: org.gradle.api.plugins.ExtraPropertiesExtension get() = + (this as org.gradle.api.plugins.ExtensionAware).extensions.getByName("ext") as org.gradle.api.plugins.ExtraPropertiesExtension + +class LazyExt(val root: Boolean = true, val lazyBlock: Project.() -> T) { + operator fun getValue(project: Project, property: KProperty<*>): T { + val key = "_ext_${property.name}" + if (!project._ext.has(key)) { + project._ext.set(key, lazyBlock(if (root) project.rootProject else project)) + } + return project._ext.get(key) as T + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Dyn.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Dyn.kt new file mode 100644 index 0000000000..514135c3f7 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Dyn.kt @@ -0,0 +1,513 @@ +package korlibs.korge.gradle.util + +import groovy.lang.* +import java.lang.reflect.* +import kotlin.math.* + +val Any?.dyn: Dyn get() = Dyn(this) + +@Suppress("DEPRECATION") +inline class Dyn(val value: Any?) : Comparable { + val dyn get() = this + val isNull get() = value == null + val isNotNull get() = value != null + + inline fun casted(): T = value as T + + @Suppress("UNCHECKED_CAST") + fun toComparable(): Comparable = when (value) { + null -> 0 as Comparable + is Comparable<*> -> value as Comparable + else -> value.toString() as Comparable + } + + //fun unop(op: String): Dyn = unop(this, op) + //fun binop(op: String, r: Dyn): Dyn = binop(this, r, op) + + operator fun unaryMinus(): Dyn = (-toDouble()).dyn + operator fun unaryPlus(): Dyn = this + fun inv(): Dyn = toInt().inv().dyn + fun not(): Dyn = (!toBool()).dyn + + operator fun plus(r: Dyn): Dyn { + val l = this + val out: Any? = when (l.value) { + is String -> l.toString() + r.toString() + is Iterable<*> -> l.toIterableAny() + r.toIterableAny() + else -> l.toDouble() + r.toDouble() + } + return out.dyn + } + operator fun minus(r: Dyn): Dyn = (this.toDouble() - r.toDouble()).dyn + operator fun times(r: Dyn): Dyn = (this.toDouble() * r.toDouble()).dyn + operator fun div(r: Dyn): Dyn = (this.toDouble() / r.toDouble()).dyn + operator fun rem(r: Dyn): Dyn = (this.toDouble() % r.toDouble()).dyn + infix fun pow(r: Dyn): Dyn = (this.toDouble().pow(r.toDouble())).dyn + infix fun bitAnd(r: Dyn): Dyn = (this.toInt() and r.toInt()).dyn + infix fun bitOr(r: Dyn): Dyn = (this.toInt() or r.toInt()).dyn + infix fun bitXor(r: Dyn): Dyn = (this.toInt() xor r.toInt()).dyn + /** Logical AND */ + infix fun and(r: Dyn): Boolean = (this.toBool() && r.toBool()) + /** Logical OR */ + infix fun or(r: Dyn): Boolean = (this.toBool() || r.toBool()) + + /** Equal */ + infix fun eq(r: Dyn): Boolean = when { + this.value is Number && r.value is Number -> this.toDouble() == r.toDouble() + this.value is String || r.value is String -> this.toString() == r.toString() + else -> this.value == r.value + } + /** Not Equal */ + infix fun ne(r: Dyn): Boolean = when { + this.value is Number && r.value is Number -> this.toDouble() != r.toDouble() + this.value is String || r.value is String -> this.toString() != r.toString() + else -> this.value != r.value + } + /** Strict EQual */ + infix fun seq(r: Dyn): Boolean = this.value === r.value + /** Strict Not Equal */ + infix fun sne(r: Dyn): Boolean = this.value !== r.value + /** Less Than */ + infix fun lt(r: Dyn): Boolean = compare(this, r) < 0 + /** Less or Equal */ + infix fun le(r: Dyn): Boolean = compare(this, r) <= 0 + /** Greater Than */ + infix fun gt(r: Dyn): Boolean = compare(this, r) > 0 + /** Greater or Equal */ + infix fun ge(r: Dyn): Boolean = compare(this, r) >= 0 + operator fun contains(r: String): Boolean = contains(r.dyn) + operator fun contains(r: Number): Boolean = contains(r.dyn) + operator fun contains(r: Dyn): Boolean { + val collection = this + val element = r + if (collection.value == element.value) return true + return when (collection.value) { + is String -> collection.value.contains(element.value.toString()) + is Set<*> -> element.value in collection.value + is Map<*, *> -> element.value in collection.value + else -> element.value in collection.toListAny() + } + } + fun coalesce(default: Dyn): Dyn = if (this.isNotNull) this else default + + override fun compareTo(other: Dyn): Int { + val l = this + val r = other + if (l.value is Number && r.value is Number) { + return l.value.toDouble().compareTo(r.value.toDouble()) + } + val lc = l.toComparable() + val rc = r.toComparable() + return if (lc::class.isInstance(rc)) lc.compareTo(rc) else -1 + } + + override fun toString(): String = toString(value) + + fun toStringOrNull(): String? { + return if (this.isNotNull) { + toString() + } else { + null + } + } + + companion object { + val global get() = dynApi.global.dyn + + fun compare(l: Dyn, r: Dyn): Int = l.compareTo(r) + fun contains(collection: Dyn, element: Dyn): Boolean = element in collection + + /* + fun unop(r: Dyn, op: String): Dyn = when (op) { + "+" -> +r + "-" -> -r + "~" -> r.inv() + "!" -> r.not() + else -> error("Not implemented unary operator '$op'") + } + + fun binop(l: Dyn, r: Dyn, op: String): Dyn { + return when (op) { + "+" -> (l + r) + "-" -> (l - r) + "*" -> (l * r) + "/" -> (l / r) + "%" -> (l % r) + "**" -> (l pow r) + "&" -> (l bitAnd r) + "|" -> (l bitOr r) + "^" -> (l bitXor r) + "&&" -> (l and r).dyn + "and" -> (l and r).dyn + "||" -> (l or r).dyn + "or" -> (l or r).dyn + "==" -> (l eq r).dyn + "!=" -> (l ne r).dyn + "===" -> (l seq r).dyn + "!==" -> (l sne r).dyn + "<" -> (l lt r).dyn + "<=" -> (l le r).dyn + ">" -> (l gt r).dyn + ">=" -> (l ge r).dyn + "in" -> r.contains(l).dyn + "contains" -> l.contains(r).dyn + "?:" -> l.coalesce(r) + else -> error("Not implemented binary operator '$op'") + } + } + */ + } + + fun toList(): List = toListAny().map { it.dyn } + fun toIterable(): Iterable = toIterableAny().map { it.dyn } + + fun toListAny(): List<*> = toIterableAny().toList() + + fun toIterableAny(): Iterable<*> = when (value) { + null -> listOf() + //is Dynamic2Iterable -> it.dynamic2Iterate() + is Iterable<*> -> value + is CharSequence -> value.toList() + is Map<*, *> -> value.toList() + else -> listOf() + } + + interface Invokable { + fun invoke(name: String, args: Array): Any? + fun invokeOrThrow(name: String, args: Array): Any? = invoke(name, args) + } + + interface SuspendInvokable { + suspend fun invoke(name: String, args: Array): Any? + } + + fun dynamicInvoke(name: String, vararg args: Any?): Dyn = when (value) { + null -> null.dyn + is Invokable -> value.invoke(name, args).dyn + else -> dynApi.invoke(value, name, args).dyn + } + + fun dynamicInvokeOrThrow(name: String, vararg args: Any?): Dyn = when (value) { + null -> error("Can't invoke '$name' on null") + is Invokable -> value.invokeOrThrow(name, args).dyn + else -> dynApi.invokeOrThrow(value, name, args).dyn + } + + suspend fun suspendDynamicInvoke(name: String, vararg args: Any?): Dyn = when (value) { + null -> null.dyn + is Invokable -> value.invoke(name, args).dyn + is SuspendInvokable -> value.invoke(name, args).dyn + else -> dynApi.suspendInvoke(value, name, args).dyn + } + + operator fun set(key: Dyn, value: Dyn) = set(key.value, value.value) + operator fun set(key: Any?, value: Dyn) = set(key, value.value) + operator fun set(key: Any?, value: Any?) { + when (value) { + is MutableMap<*, *> -> (this.value as MutableMap)[key] = value + is MutableList<*> -> (this.value as MutableList)[key.dyn.toInt()] = value + else -> dynApi.set(this.value, key.toString(), value) + } + } + + operator fun get(key: Dyn): Dyn = get(key.value) + operator fun get(key: Any?): Dyn = _getOrThrow(key, doThrow = false) + + fun getOrNull(key: Any?): Dyn? = _getOrThrow(key, doThrow = false).orNull + fun getOrThrow(key: Any?): Dyn = _getOrThrow(key, doThrow = true) + + private fun _getOrThrow(key: Any?, doThrow: Boolean): Dyn = when (value) { + null -> if (doThrow) throw NullPointerException("Trying to access '$key'") else null.dyn + is Map<*, *> -> (value as Map)[key].dyn + is GroovyObject -> value.getProperty(key.toString()).dyn + is List<*> -> value[key.dyn.toInt()].dyn + else -> dynApi.get(value, key.toString()).dyn + } + + suspend fun suspendSet(key: Dyn, value: Dyn) = suspendSet(key.value, value.value) + suspend fun suspendSet(key: Any?, value: Dyn) = suspendSet(key, value.value) + suspend fun suspendSet(key: Any?, value: Any?) { + when (value) { + is MutableMap<*, *> -> (this.value as MutableMap)[key] = value + is MutableList<*> -> (this.value as MutableList)[key.dyn.toInt()] = value + else -> dynApi.suspendSet(this.value, key.toString(), value) + } + } + + suspend fun suspendGet(key: Dyn): Dyn = suspendGet(key.value) + suspend fun suspendGet(key: Any?): Dyn = when (value) { + null -> null.dyn + is Map<*, *> -> (value as Map)[key].dyn + is List<*> -> value[key.dyn.toInt()].dyn + else -> dynApi.suspendGet(value, key.toString()).dyn + } + + val orNull: Dyn? get() = value?.dyn + val mapAny: Map get() = if (value is Map<*, *>) value as Map else LinkedHashMap() + val listAny: List get() = if (value == null) listOf() else if (value is List<*>) value else if (value is Iterable<*>) value.toList() else listOf(value) + val keysAny: List get() = if (value is Map<*, *>) value.keys.toList() else listOf() + + val map: Map get() = mapAny.map { it.key.dyn to it.value.dyn }.toMap() + val list: List get() = listAny.map { it.dyn } + val keys: List get() = keysAny.map { it.dyn } + + fun String.toNumber(): Number = (this.toIntOrNull() as? Number?) ?: this.toDoubleOrNull() ?: Double.NaN + + fun toBool(extraStrings: Boolean = true): Boolean = when (value) { + null -> false + is Boolean -> value + else -> toBoolOrNull(extraStrings) ?: true + } + + fun toBoolOrNull(extraStrings: Boolean = true): Boolean? = when (value) { + null -> null + is Boolean -> value + is Number -> toDouble() != 0.0 + is String -> { + if (extraStrings) { + when (value.toLowerCase()) { + "", "0", "false", "NaN", "null", "undefined", "ko", "no" -> false + else -> true + } + } else { + value.isNotEmpty() && value != "0" && value != "false" + } + } + else -> null + } + + fun toNumber(): Number = when (value) { + null -> 0 + is Number -> value + is Boolean -> if (value) 1 else 0 + is String -> value.toIntSafe() ?: value.toDoubleSafe() ?: 0 + //else -> it.toString().toNumber() + else -> value.toString().toNumber() + } + + private fun toString(value: Any?): String = when (value) { + null -> "" + is String -> value + is Double -> { + if (value == value.toInt().toDouble()) { + value.toInt().toString() + } else { + value.toString() + } + } + is Iterable<*> -> "[" + value.joinToString(", ") { toString(it) } + "]" + is Map<*, *> -> "{" + value.map { toString(it.key).quote() + ": " + toString(it.value) }.joinToString(", ") + "}" + else -> value.toString() + } + + fun toByte(): Byte = toNumber().toByte() + fun toChar(): Char = when { + value is Char -> value + value is String && (value.length == 1) -> value.first() + else -> toNumber().toChar() + } + + fun toShort(): Short = toNumber().toShort() + fun toInt(): Int = toNumber().toInt() + fun toLong(): Long = toNumber().toLong() + fun toFloat(): Float = toNumber().toFloat() + fun toDouble(): Double = toNumber().toDouble() + + fun toBoolOrNull(): Boolean? = when (value) { + is Boolean -> value + is String -> value == "1" || value == "true" || value == "on" + is Number -> toInt() != 0 + else -> null + } + + fun toIntOrNull(): Int? = when (value) { + is Number -> toInt() + is String -> value.toIntSafe() + else -> null + } + + fun toLongOrNull(): Long? = when (value) { + is Number -> toLong() + is String -> value.toLongSafe() + else -> null + } + + fun toDoubleOrNull(): Double? = when (value) { + is Number -> toDouble() + is String -> value.toDoubleSafe() + else -> null + } + + fun toIntDefault(default: Int = 0): Int = when (value) { + is Number -> toInt() + is String -> value.toIntSafe(10) ?: default + else -> default + } + + fun toLongDefault(default: Long = 0L): Long = when (value) { + is Number -> toLong() + is String -> value.toLongSafe(10) ?: default + else -> default + } + + fun toFloatDefault(default: Float = 0f): Float = when (value) { + is Number -> toFloat() + is String -> toFloat() + else -> default + } + + fun toDoubleDefault(default: Double = 0.0): Double = when (value) { + is Number -> toDouble() + is String -> toDouble() + else -> default + } + + val str: String get() = toString() + val int: Int get() = toIntDefault() + val bool: Boolean get() = toBoolOrNull() ?: false + val float: Float get() = toFloatDefault() + val double: Double get() = toDoubleDefault() + val long: Long get() = toLongDefault() + + val intArray: IntArray get() = value as? IntArray ?: (value as? List)?.toIntArray() ?: list.map { it.dyn.int }.toIntArray() + val floatArray: FloatArray get() = value as? FloatArray ?: (value as? List)?.toFloatArray() ?: list.map { it.dyn.float }.toFloatArray() + val doubleArray: DoubleArray get() = value as? DoubleArray ?: (value as? List)?.toDoubleArray() ?: list.map { it.dyn.double }.toDoubleArray() + val longArray: LongArray get() = value as? LongArray ?: list.map { it.dyn.long }.toLongArray() +} + +private fun String.toIntSafe(radix: Int = 10) = this.toIntOrNull(radix) +private fun String.toDoubleSafe() = this.toDoubleOrNull() +private fun String.toLongSafe(radix: Int = 10) = this.toLongOrNull(radix) + +internal object dynApi { + class JavaPackage(val name: String) + + val global: Any? = JavaPackage("") + + private fun tryGetField(clazz: Class<*>, name: String): Field? { + val field = runCatching { clazz.getDeclaredField(name) }.getOrNull() + return when { + field != null -> field.apply { isAccessible = true } + clazz.superclass != null -> return tryGetField(clazz.superclass, name) + else -> null + } + } + + private fun tryGetMethod(clazz: Class<*>, name: String, args: Array?): Method? { + val methods = (clazz.interfaces + clazz).flatMap { it.allDeclaredMethods.filter { it.name == name } } + val method = when (methods.size) { + 0 -> null + 1 -> methods.first() + else -> { + if (args != null) { + val methodsSameArity = methods.filter { it.parameterTypes.size == args.size } + val argTypes = args.map { if (it == null) null else it::class.javaObjectType } + methodsSameArity.firstOrNull { + it.parameterTypes.toList().zip(argTypes).all { + (it.second == null) || it.first.kotlin.javaObjectType.isAssignableFrom(it.second) + } + } + } else { + methods.first() + } + } + } + return when { + method != null -> method.apply { isAccessible = true } + clazz.superclass != null -> return tryGetMethod(clazz.superclass, name, args) + else -> null + } + } + + fun get(instance: Any?, key: String): Any? = getBase(instance, key, doThrow = false) + + fun set(instance: Any?, key: String, value: Any?) { + if (instance == null) return + + val static = instance is Class<*> + val clazz: Class<*> = if (static) instance as Class<*> else instance.javaClass + + val method = tryGetMethod(clazz, "set${key.capitalize()}", null) + if (method != null) { + method.invoke(if (static) null else instance, value) + return + } + val field = tryGetField(clazz, key) + if (field != null) { + field.set(if (static) null else instance, value) + return + } + } + + fun getOrThrow(instance: Any?, key: String): Any? { + return getBase(instance, key, doThrow = true) + } + + fun invoke(instance: Any?, key: String, args: Array): Any? { + return invokeBase(instance, key, args, doThrow = false) + } + + fun invokeOrThrow(instance: Any?, key: String, args: Array): Any? { + return invokeBase(instance, key, args, doThrow = true) + } + + suspend fun suspendGet(instance: Any?, key: String): Any? = get(instance, key) + suspend fun suspendSet(instance: Any?, key: String, value: Any?): Unit = set(instance, key, value) + suspend fun suspendInvoke(instance: Any?, key: String, args: Array): Any? = invoke(instance, key, args) + + fun getBase(instance: Any?, key: String, doThrow: Boolean): Any? { + if (instance == null) { + if (doThrow) error("Can't get '$key' on null") + return null + } + + val static = instance is Class<*> + val clazz: Class<*> = if (static) instance as Class<*> else instance.javaClass + + if (instance is JavaPackage) { + val path = "${instance.name}.$key".trim('.') + return try { + java.lang.Class.forName(path) + } catch (e: ClassNotFoundException) { + JavaPackage(path) + } + } + val method = tryGetMethod(clazz, "get${key.capitalize()}", null) + if (method != null) { + return method.invoke(if (static) null else instance) + } + val field = tryGetField(clazz, key) + if (field != null) { + return field.get(if (static) null else instance) + } + if (doThrow) { + error("Can't find suitable fields or getters for '$key'") + } + return null + } + + fun invokeBase(instance: Any?, key: String, args: Array, doThrow: Boolean): Any? { + if (instance == null) { + if (doThrow) error("Can't invoke '$key' on null") + return null + } + val method = tryGetMethod(if (instance is Class<*>) instance else instance.javaClass, key, args) + if (method == null) { + if (doThrow) error("Can't find method '$key' on ${instance::class}") + return null + } + return try { + method.invoke(if (instance is Class<*>) null else instance, *args) + } catch (e: InvocationTargetException) { + throw e.targetException ?: e + } + } +} + +private val Class<*>.allDeclaredFields: List + get() = this.declaredFields.toList() + (this.superclass?.allDeclaredFields?.toList() ?: listOf()) + +private fun Class<*>.isSubtypeOf(that: Class<*>) = that.isAssignableFrom(this) + +private val Class<*>.allDeclaredMethods: List + get() = this.declaredMethods.toList() + (this.superclass?.allDeclaredMethods?.toList() ?: listOf()) diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/DynamicPluginExec.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/DynamicPluginExec.kt new file mode 100644 index 0000000000..df155c6d7a --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/DynamicPluginExec.kt @@ -0,0 +1,23 @@ +package korlibs.korge.gradle.util + +import java.io.File +import java.net.URLClassLoader + +fun executeInPlugin(classPaths: Iterable, className: String, methodName: String, throws: Boolean = false, args: (ClassLoader) -> List): Any? { + val classPaths = classPaths.toList().map { it.toURL() } + //println(classPaths) + return URLClassLoader(classPaths.toTypedArray(), ClassLoader.getSystemClassLoader()).use { classLoader -> + val clazz = classLoader.loadClass(className) + try { + clazz.methods.first { it.name == methodName }.invoke(null, *args(classLoader).toTypedArray()) + } catch (e: java.lang.reflect.InvocationTargetException) { + val re = (e.targetException ?: e) + if (throws) throw re + re.printStackTrace() + System.err.println(re.toString()) + null + } + }.also { + System.gc() + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/ExecExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/ExecExt.kt new file mode 100644 index 0000000000..fa6bd2b74f --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/ExecExt.kt @@ -0,0 +1,58 @@ +package korlibs.korge.gradle.util + +import org.apache.tools.ant.taskdefs.condition.* +import org.gradle.api.* +import org.gradle.process.* +import java.io.* + +fun Project.debugExecSpec(exec: ExecSpec) { + logger.warn("COMMAND: ${exec.commandLine.joinToString(" ")}") + //println("COMMAND: ${exec.commandLine.joinToString(" ")}") +} + +/* +class LoggerOutputStream(val logger: org.gradle.api.logging.Logger, val prefix: String) : OutputStream() { + val buffer = ByteArrayOutputStream() + + override fun write(b: Int) { + if (b == 13 || b == 10) { + val line = buffer.toString("UTF-8") + println("$prefix: $line") + buffer.reset() + } else { + buffer.write(b) + } + } +} +*/ + +fun Project.execThis(block: ExecSpec.() -> Unit): ExecResult = exec(block) + +fun Project.execLogger(action: (ExecSpec) -> Unit): ExecResult { + return execThis { + action(this) + //it.standardOutput = LoggerOutputStream(logger, "OUT") + //it.errorOutput = LoggerOutputStream(logger, "ERR") + debugExecSpec(this) + } +} + +fun ExecSpec.commandLineCompat(vararg args: String): ExecSpec { + return when { + Os.isFamily(Os.FAMILY_WINDOWS) -> commandLine("cmd", "/c", *args) + else -> commandLine(*args) + } +} + +fun Project.execOutput(vararg cmds: String, log: Boolean = true): String { + val stdout = ByteArrayOutputStream() + execThis { + commandLineCompat(*cmds) + standardOutput = stdout + //errorOutput = stdout + if (log) { + debugExecSpec(this) + } + } + return stdout.toString("UTF-8") +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/FileExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/FileExt.kt new file mode 100644 index 0000000000..963c853dc8 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/FileExt.kt @@ -0,0 +1,47 @@ +package korlibs.korge.gradle.util + +import java.io.File +import java.nio.charset.* + +val File.absolutePathWithSlash: String get() = if (this.isDirectory) "$absolutePath/" else absolutePath + +fun File.isDescendantOf(base: File): Boolean { + return this.absolutePathWithSlash.startsWith(base.absolutePathWithSlash) +} + +fun File.takeIfExists() = this.takeIf { it.exists() } +fun File.takeIfNotExists() = this.takeIf { !it.exists() } +fun File.ensureParents() = this.apply { parentFile.mkdirs() } +fun File.conditionally(ifNotExists: Boolean = true, block: File.() -> T): T? = if (!ifNotExists || !this.exists()) block() else null +fun File.always(block: File.() -> T): T = block() +operator fun File.get(name: String) = File(this, name) + +fun File.getFirstRegexOrNull(regex: Regex): File? = this + .listFiles { dir, name -> regex.containsMatchIn(name) } + ?.firstOrNull() + +fun File.getFirstRegexOrFail(regex: Regex) = this.getFirstRegexOrNull(regex) + ?: error("Can't find file matching '$regex' in folder '$this'") + +fun File.writeTextIfChanged(text: String, charset: Charset = Charsets.UTF_8) { + val originalText = this.takeIf { it.exists() }?.readText(charset) + if (originalText != text) { + if (!parentFile.isDirectory) parentFile.mkdirs() + writeText(text, charset) + } +} + +fun File.writeBytesIfChanged(bytes: ByteArray) { + val originalBytes = this.takeIf { it.exists() }?.readBytes() + if (originalBytes == null || !bytes.contentEquals(originalBytes)) { + writeBytes(bytes) + } +} + +class FileList(val files: List) : Collection by files { + constructor(vararg files: File) : this(files.toList()) + fun exists() = files.any { it.exists() } + fun exists(name: String) = files.any { it[name].exists() } + val firstExistantFile: File? get() = files.firstOrNull { it.exists() } + fun takeIfExists() = firstExistantFile +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/GradleExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/GradleExt.kt new file mode 100644 index 0000000000..0f56e1f5cd --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/GradleExt.kt @@ -0,0 +1,30 @@ +package korlibs.korge.gradle.util + +import org.gradle.api.* +import org.gradle.api.plugins.PluginContainer +import kotlin.reflect.* + +fun PluginContainer.applyOnce(id: String) { + if (!hasPlugin(id)) { + apply(id) + } +} + +fun > PluginContainer.applyOnce(clazz: Class) { + if (!hasPlugin(clazz)) { + apply(clazz) + } +} + +inline fun > PluginContainer.applyOnce() { + applyOnce(T::class.java) +} + +fun Project.ordered(vararg dependencyPaths: String): List { + val dependencies = dependencyPaths.map { tasks.getByPath(it) } + for (n in 0 until dependencies.size - 1) { + dependencies[n + 1].mustRunAfter(dependencies[n]) + } + return dependencies +} + diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/GradleTools.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/GradleTools.kt new file mode 100644 index 0000000000..b026e2e7c5 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/GradleTools.kt @@ -0,0 +1,34 @@ +package korlibs.korge.gradle.util + +import groovy.lang.* +import org.gradle.api.* +import org.gradle.api.artifacts.dsl.DependencyHandler +import org.gradle.api.tasks.TaskContainer + +operator fun Project.invoke(callback: Project.() -> T): T = callback(this) +operator fun DependencyHandler.invoke(callback: DependencyHandler.() -> T): T = callback(this) + +open class LambdaClosure(val lambda: (value: T) -> TR) : Closure(Unit) { + @Suppress("unused") + fun doCall(vararg arguments: T) = lambda(arguments[0]) + + override fun getProperty(property: String): Any = "lambda" +} + +//inline class TaskName(val name: String) + +inline fun TaskContainer.create(name: String, callback: T.() -> Unit = {}) = create(name, T::class.java).apply(callback) +inline fun TaskContainer.createTyped(name: String, callback: T.() -> Unit = {}) = create(name, T::class.java).apply(callback) + + +inline fun ignoreErrors(action: () -> Unit) { + try { + action() + } catch (e: Throwable) { + e.printStackTrace() + } +} + +fun Project.getIfExists(name: String): T? = if (this.hasProperty(name)) this.property(name) as T else null + +operator fun NamedDomainObjectSet.get(key: String): T = this.getByName(key) diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/GroovyDynamic.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/GroovyDynamic.kt new file mode 100644 index 0000000000..e1e6fcb108 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/GroovyDynamic.kt @@ -0,0 +1,10 @@ +package korlibs.korge.gradle.util + +import groovy.lang.* +import org.gradle.api.* + +fun Project.closure(callback: () -> T) = GroovyClosure(this, callback) + +fun GroovyClosure(owner: Any?, callback: () -> T): Closure = object : Closure(owner) { + override fun call(): T = callback() +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Hex.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Hex.kt new file mode 100644 index 0000000000..b2652d8c60 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Hex.kt @@ -0,0 +1,68 @@ +package korlibs.korge.gradle.util + +object Hex { + private const val DIGITS = "0123456789ABCDEF" + val DIGITS_UPPER = DIGITS.toUpperCase() + val DIGITS_LOWER = DIGITS.toLowerCase() + + fun decodeChar(c: Char): Int = when (c) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> -1 + } + + fun encodeCharLower(v: Int): Char = DIGITS_LOWER[v] + fun encodeCharUpper(v: Int): Char = DIGITS_UPPER[v] + + fun isHexDigit(c: Char): Boolean = decodeChar(c) >= 0 + + fun decode(str: String): ByteArray { + val out = ByteArray((str.length + 1) / 2) + var opos = 0 + var nibbles = 0 + var value = 0 + for (c in str) { + val vv = decodeChar(c) + if (vv >= 0) { + value = (value shl 4) or vv + nibbles++ + } + if (nibbles == 2) { + out[opos++] = value.toByte() + nibbles = 0 + value = 0 + } + } + return if (opos != out.size) out.copyOf(opos) else out + } + + fun encodeLower(src: ByteArray): String = encodeBase(src, DIGITS_LOWER) + fun encodeUpper(src: ByteArray): String = encodeBase(src, DIGITS_UPPER) + + private fun encodeBase(data: ByteArray, digits: String = DIGITS): String { + val out = StringBuilder(data.size * 2) + for (n in data.indices) { + val v = data[n].toInt() and 0xFF + out.append(digits[(v ushr 4) and 0xF]) + out.append(digits[(v ushr 0) and 0xF]) + } + return out.toString() + } +} + +val List.unhexIgnoreSpaces get() = joinToString("").unhexIgnoreSpaces +val String.unhexIgnoreSpaces get() = this.replace(" ", "").replace("\n", "").replace("\r", "").unhex +val String.unhex get() = Hex.decode(this) +val ByteArray.hex get() = Hex.encodeLower(this) + +val Int.hex: String get() = "0x$shex" +val Int.shex: String + get() { + var out = "" + for (n in 0 until 8) { + val v = (this ushr ((7 - n) * 4)) and 0xF + out += Hex.encodeCharUpper(v) + } + return out + } diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/HttpServerTools.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/HttpServerTools.kt new file mode 100644 index 0000000000..439515ae65 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/HttpServerTools.kt @@ -0,0 +1,197 @@ +package korlibs.korge.gradle.util + +import com.sun.net.httpserver.* +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.* +import java.io.* +import java.lang.management.* +import java.net.* +import java.nio.charset.* +import java.nio.file.* +import java.util.concurrent.atomic.* +import kotlin.math.* + +class DecoratedHttpServer(val server: HttpServer) { + val port get() = server.address.port + var updateVersion = AtomicInteger(0) +} + +fun staticHttpServer(folder: File, address: String = "127.0.0.1", port: Int = 0, callback: (server: DecoratedHttpServer) -> Unit) { + val server = staticHttpServer(folder, address, port) + try { + callback(server) + } finally { + server.server.stop(0) + } +} + +fun getIpListFromIp(ip: String): List = when (ip) { + "0.0.0.0" -> try { + NetworkInterface.getNetworkInterfaces().toList() + .flatMap { it.inetAddresses.toList() } + .filterIsInstance() + .map { it.hostAddress } + } catch (e: Throwable) { + e.printStackTrace() + listOf(ip) + } + else -> listOf(ip) +} + +fun staticHttpServer(folder: File, address: String = "127.0.0.1", port: Int = 0): DecoratedHttpServer { + val absFolder = folder.absoluteFile + val server = HttpServer.create(InetSocketAddress(address, port), 0) + val decorated = DecoratedHttpServer(server) + + println("Listening... (${ManagementFactory.getRuntimeMXBean().name}-${Thread.currentThread()}):") + println("Serving... file://$folder") + for (raddr in getIpListFromIp(address)) { + println(" at http://$raddr:${server.address.port}/") + } + server.createContext("/") { t -> + //println("t.requestURI.path=${t.requestURI.path}") + if (t.requestURI.path == "/__version") { + t.respond(TextContent("${decorated.updateVersion.get()}")) + } else { + val requested = File(folder, t.requestURI.path).absoluteFile + + if (requested.absolutePath.startsWith(absFolder.absolutePath)) { + val req = if (requested.exists() && requested.isDirectory) File(requested, "index.html") else requested + when { + req.exists() && !req.isDirectory -> t.respond(FileContent(req)) + else -> t.respond(TextContent("

404 - Not Found

", contentType = "text/html"), code = 404) + } + } else { + t.respond(TextContent("

500 - Internal Server Error

", contentType = "text/html"), code = 500) + } + } + } + server.start() + return decorated +} + +interface RangedContent { + val length: Long + val contentType: String + fun write(out: OutputStream, range: LongRange) +} + +class FileContent(val file: File) : RangedContent { + override val length: Long by lazy { file.length() } + override val contentType: String by lazy { file.miniMimeType() } + + override fun write(out: OutputStream, range: LongRange) { + val len = (range.endInclusive - range.start) + 1 + //println("range=$range, len=$len") + FileInputStream(file).use { f -> + f.skip(range.start) + var remaining = len + val temp = ByteArray(64 * 1024) + while (remaining > 0L) { + val read = f.read(temp, 0, min(remaining, temp.size.toLong()).toInt()) + if (read <= 0) break + //println("write $read") + out.write(temp, 0, read) + remaining -= read + } + //println("end") + } + } +} + +open class TextContent(val text: String, override val contentType: String = "text/plain", val charset: Charset = Charsets.UTF_8) : ByteArrayContent(text.toByteArray(charset), contentType) + +open class ByteArrayContent(val data: ByteArray, override val contentType: String) : RangedContent { + override val length: Long get() = data.size.toLong() + + override fun write(out: OutputStream, range: LongRange) { + out.write(data, range.start.toInt(), ((range.endInclusive - range.start) + 1).toInt()) + } +} + +fun HttpExchange.respond(content: RangedContent, headers: List> = listOf(), code: Int? = null) { + try { + val range = requestHeaders.getFirst("Range") + val reqRange = if (range != null) { + val rangeStr = range.removePrefix("bytes=") + val parts = rangeStr.split("-", limit = 2) + val start = parts.getOrNull(0)?.toLongOrNull() ?: error("Invalid request. Range: $range") + val endInclusive = parts.getOrNull(1)?.toLongOrNull() ?: Long.MAX_VALUE + start.coerceIn(0L, content.length - 1)..endInclusive.coerceIn(0L, content.length - 1) + } else { + null + } + val totalRange = 0L until content.length + + val partial = reqRange != null + val length = if (partial) reqRange!!.endInclusive - reqRange.start + 1 else content.length + + responseHeaders.add("Content-Type", content.contentType) + responseHeaders.add("Accept-Ranges", "bytes") + //println("Partial: $content, $partial, $range, $reqRange") + if (partial) { + responseHeaders.set( + "Content-Range", + "bytes ${reqRange!!.start}-${reqRange!!.endInclusive}/${content.length}" + ) + } + + val bodyLength = when { + this.requestMethod.equals("head", ignoreCase = true) -> { + responseHeaders.add("Content-Length", "$length") + -1 + } + else -> { + length + } + } + + for (header in headers) { + responseHeaders.add(header.first, header.second) + } + + sendResponseHeaders(if (partial) 206 else code ?: 200, bodyLength) + + // Send body if not HEAD + if (!this.requestMethod.equals("HEAD", ignoreCase = true)) { + //println("${this.requestMethod}") + responseBody.use { os -> + content.write(os, reqRange ?: totalRange) + } + } else { + //println("HEAD") + } + } catch (e: Throwable) { + e.printStackTrace() + } +} + +fun File.miniMimeType() = when (this.extension.toLowerCase()) { + "htm", "html" -> "text/html" + "css" -> "text/css" + "txt" -> "text/plain" + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "svg" -> "image/svg+xml" + "mp3" -> "audio/mpeg" + "wasm" -> "application/wasm" + "js", "mjs" -> "text/javascript" + else -> if (this.exists()) Files.probeContentType(this.toPath()) ?: "application/octet-stream" else "text/plain" +} + +private val isWindows get() = Os.isFamily(Os.FAMILY_WINDOWS) +private val isMacos get() = Os.isFamily(Os.FAMILY_MAC) +private val isLinux get() = Os.isFamily(Os.FAMILY_UNIX) && !isMacos + +fun openBrowser(url: String) { + ProcessBuilder().command(buildList { + when { + isWindows -> { + addAll(listOf("cmd", "/c", "explorer.exe $url")) + //isIgnoreExitValue = true + } + isLinux -> addAll(listOf("xdg-open", url)) + else -> addAll(listOf("open", url)) + } + }).start() +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/ImageUtils.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/ImageUtils.kt new file mode 100644 index 0000000000..67aabb8f1b --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/ImageUtils.kt @@ -0,0 +1,24 @@ +package korlibs.korge.gradle.util + +import java.awt.Image +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO + +fun BufferedImage.encodePNG(): ByteArray = + ByteArrayOutputStream().also { ImageIO.write(this, "png", it) }.toByteArray() + +fun Image.toBufferedImage(): BufferedImage { + if (this is BufferedImage && this.type == BufferedImage.TYPE_INT_ARGB) return this + val bimage = BufferedImage(this.getWidth(null), this.getHeight(null), BufferedImage.TYPE_INT_ARGB) + val bGr = bimage.createGraphics() + bGr.drawImage(this, 0, 0, null) + bGr.dispose() + return bimage +} + +fun Image.getScaledInstance(width: Int, height: Int) = getScaledInstance(width, height, Image.SCALE_SMOOTH) + +fun ByteArray.decodeImage() = ImageIO.read(this.inputStream()).toBufferedImage() + +val BufferedImage.area get() = width * height diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Indenter.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Indenter.kt new file mode 100644 index 0000000000..b65ab66768 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Indenter.kt @@ -0,0 +1,65 @@ +package korlibs.korge.gradle.util + +inline fun Indenter(callback: Indenter.() -> Unit): String = Indenter().apply(callback).toString() + +class Indenter { + @PublishedApi + internal val cmds = arrayListOf() + + @PublishedApi + internal object Indent + @PublishedApi + internal object Unindent + + fun line(str: String) { + cmds += str + } + + inline fun line(str: String, callback: () -> Unit) { + line("$str {") + indent { + callback() + } + line("}") + } + + inline fun indent() { cmds += Indent } + inline fun unindent() { cmds += Unindent } + + inline fun indent(callback: () -> T): T { + indent() + try { + return callback() + } finally { + unindent() + } + } + + object Indents { + val indents = Array(128) { "" }.apply { + val builder = StringBuilder() + for (n in 0 until size) { + this[n] = builder.toString() + builder.append('\t') + } + } + + operator fun get(index: Int): String = indents.getOrNull(index) ?: error("Too much indentation ($index)") + } + + override fun toString(): String = buildString { + var indent = 0 + for (cmd in cmds) { + when (cmd) { + Indent -> indent++ + Unindent -> indent-- + is String -> { + for (line in cmd.split("\n")) { + append(Indents[indent]) + append("$line\n") + } + } + } + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/InputStreamExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/InputStreamExt.kt new file mode 100644 index 0000000000..fd9b2d0f37 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/InputStreamExt.kt @@ -0,0 +1,98 @@ +package korlibs.korge.gradle.util + +import java.io.* + +class ByteArraySlice(val ba: ByteArray, val pos: Int = 0, val size: Int = ba.size - pos) { + fun sliceRange(range: IntRange): ByteArraySlice { + return ByteArraySlice(ba, pos + range.first, range.last - range.first + 1) + } + val length: Int get() = size + operator fun get(index: Int): Byte = ba[pos + index] + fun sliceArray(range: IntRange): ByteArray { + return ba.sliceArray((pos + range.first) .. (pos + range.last)) + } + + override fun toString(): String= "ByteArraySlice[$size]" +} + +class ByteArraySimpleInputStream( + val data: ByteArraySlice, + var pos: Int = 0 +) { + override fun toString(): String = "ByteArraySimpleInputStream([data=${data.ba.size}, pos=${data.pos}, len=${data.length}], pos=$pos)" + + val available: Int get() = length - pos + val length: Int get() = data.size + + fun read(): Int { + if (pos >= length) return -1 + return data[pos++].toInt() and 0xFF + } + + fun readU8(): Int { + val v = this.read() + if (v < 0) error("Can't read byte at $pos in $data") + return v + } + + fun readU16LE(): Int { + val v0 = readU8() + val v1 = readU8() + return (v0 shl 0) or (v1 shl 8) + } + + fun readS16LE(): Int = (readU16LE() shl 16) shr 16 + + fun skip(count: Int): Int { + val oldPos = pos + pos += count + return oldPos + } + + fun readS32LE(): Int { + val v0 = readU8() + val v1 = readU8() + val v2 = readU8() + val v3 = readU8() + return (v0 shl 0) or (v1 shl 8) or (v2 shl 16) or (v3 shl 24) + } + + fun readU32LE(): Long { + return readS32LE().toLong() and 0xFFFFFFFFL + } + + fun readStream(count: Int): ByteArraySimpleInputStream { + val pos = skip(count) + return ByteArraySimpleInputStream(data.sliceRange(pos until (pos + count)), 0) + } + + fun readBytes(count: Int): ByteArray { + val start = skip(count) + return data.sliceArray(start until (start + count)) + } +} + +fun InputStream.readU8(): Int { + val v = this.read() + if (v < 0) error("Can't read byte") + return v +} + +fun InputStream.readU16LE(): Int { + val v0 = readU8() + val v1 = readU8() + return (v0 shl 0) or (v1 shl 8) +} + +fun InputStream.readS32LE(): Int { + val v0 = readU8() + val v1 = readU8() + val v2 = readU8() + val v3 = readU8() + return (v0 shl 0) or (v1 shl 8) or (v2 shl 16) or (v3 shl 24) +} + +fun InputStream.readU32LE(): Long { + return readS32LE().toLong() and 0xFFFFFFFFL +} + diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/JavaMinVersion.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/JavaMinVersion.kt new file mode 100644 index 0000000000..9467655ccb --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/JavaMinVersion.kt @@ -0,0 +1,12 @@ +package korlibs.korge.gradle.util + +import korlibs.* + +fun checkMinimumJavaVersion() { + val javaVersionProp = System.getProperty("java.version") ?: "unknown" + val javaVersion = currentJavaVersion() + + if (javaVersion < 11) { + error("Java 11 or greater is is required, but found $javaVersion - $javaVersionProp") + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Json.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Json.kt new file mode 100644 index 0000000000..329fa33c92 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Json.kt @@ -0,0 +1,182 @@ +package korlibs.korge.gradle.util + +import java.io.* +import kotlin.math.* + +object Json { + fun parse(s: String): Any? = parse(StrReader(s)) + fun stringify(obj: Any?) = StringBuilder().apply { stringify(obj, this) }.toString() + + interface CustomSerializer { + fun encodeToJson(b: StringBuilder) + } + + private fun parse(s: StrReader): Any? = when (val ic = s.skipSpaces().read()) { + '{' -> LinkedHashMap().apply { + obj@ while (true) { + when (s.skipSpaces().read()) { + '}' -> break@obj; ',' -> continue@obj; else -> s.unread() + } + val key = parse(s) as String + s.skipSpaces().expect(':') + val value = parse(s) + this[key] = value + } + } + '[' -> arrayListOf().apply { + array@ while (true) { + when (s.skipSpaces().read()) { + ']' -> break@array; ',' -> continue@array; else -> s.unread() + } + val item = parse(s) + this += item + } + } + '-', '+', in '0'..'9' -> { + s.unread() + val res = s.readWhile { (it in '0'..'9') || it == '.' || it == 'e' || it == 'E' || it == '-' || it == '+' } + val dres = res.toDouble() + if (dres.toInt().toDouble() == dres) dres.toInt() else dres + } + 't', 'f', 'n' -> { + s.unread() + when { + s.tryRead("true") -> true + s.tryRead("false") -> false + s.tryRead("null") -> null + else -> throw IOException("Invalid JSON") + } + } + '"' -> { + s.unread() + s.readStringLit() + } + else -> throw IOException("Not expected '$ic'") + } + + fun stringify(obj: Any?, b: StringBuilder) { + when (obj) { + null -> b.append("null") + is Boolean -> b.append(if (obj) "true" else "false") + is Map<*, *> -> { + b.append('{') + for ((i, v) in obj.entries.withIndex()) { + if (i != 0) b.append(',') + stringify(v.key, b) + b.append(':') + stringify(v.value, b) + } + b.append('}') + } + is Iterable<*> -> { + b.append('[') + for ((i, v) in obj.withIndex()) { + if (i != 0) b.append(',') + stringify(v, b) + } + b.append(']') + } + is Enum<*> -> encodeString(obj.name, b) + is String -> encodeString(obj, b) + is Number -> b.append("$obj") + is CustomSerializer -> obj.encodeToJson(b) + else -> throw RuntimeException("Don't know how to serialize $obj") //encode(ClassFactory(obj::class).toMap(obj), b) + } + } + + private fun encodeString(str: String) = StringBuilder().apply { encodeString(str, this) }.toString() + + private fun encodeString(str: String, b: StringBuilder) { + b.append('"') + for (c in str) { + when (c) { + '\\' -> b.append("\\\\"); '/' -> b.append("\\/"); '\'' -> b.append("\\'") + '"' -> b.append("\\\""); '\b' -> b.append("\\b"); '\u000c' -> b.append("\\f") + '\n' -> b.append("\\n"); '\r' -> b.append("\\r"); '\t' -> b.append("\\t") + else -> b.append(c) + } + } + b.append('"') + } +} + +fun String.fromJson(): Any? = Json.parse(this) +fun Map<*, *>.toJson(pretty: Boolean = false): String = Json.stringify(this) + +private class StrReader(val str: String, val file: String = "file", var pos: Int = 0) { + val length: Int = this.str.length + val hasMore: Boolean get() = (this.pos < this.str.length) + + inline fun slice(action: () -> Unit): String? { + val start = this.pos + action() + val end = this.pos + return if (end > start) this.slice(start, end) else null + } + + fun slice(start: Int, end: Int): String = this.str.substring(start, end) + fun peek(count: Int): String = substr(this.pos, count) + fun peekChar(): Char = if (hasMore) this.str[this.pos] else '\u0000' + fun read(count: Int): String = this.peek(count).apply { skip(count) } + inline fun skipWhile(filter: (Char) -> Boolean) { while (hasMore && filter(this.peekChar())) this.readChar() } + + inline fun readWhile(filter: (Char) -> Boolean) = this.slice { skipWhile(filter) } ?: "" + fun unread(count: Int = 1) = this.apply { this.pos -= count; } + fun readChar(): Char = if (hasMore) this.str[this.pos++] else '\u0000' + fun read(): Char = if (hasMore) this.str[this.pos++] else '\u0000' + + fun readExpect(expected: String): String { + val readed = this.read(expected.length) + if (readed != expected) throw IllegalArgumentException("Expected '$expected' but found '$readed' at $pos") + return readed + } + + fun expect(expected: Char) = readExpect("$expected") + fun skip(count: Int = 1) = this.apply { this.pos += count; } + private fun substr(pos: Int, length: Int): String { + return this.str.substring(min(pos, this.length), min(pos + length, this.length)) + } + + fun skipSpaces() = this.apply { this.skipWhile { it.isWhitespace() } } + + fun tryRead(str: String): Boolean { + if (peek(str.length) == str) { + skip(str.length) + return true + } + return false + } + + fun readStringLit(reportErrors: Boolean = true): String { + val out = StringBuilder() + val quotec = read() + when (quotec) { + '"', '\'' -> Unit + else -> throw RuntimeException("Invalid string literal") + } + var closed = false + while (hasMore) { + val c = read() + if (c == '\\') { + val cc = read() + out.append( + when (cc) { + '\\' -> '\\'; '/' -> '/'; '\'' -> '\''; '"' -> '"' + 'b' -> '\b'; 'f' -> '\u000c'; 'n' -> '\n'; 'r' -> '\r'; 't' -> '\t' + 'u' -> read(4).toInt(0x10).toChar() + else -> throw IOException("Invalid char '$cc'") + } + ) + } else if (c == quotec) { + closed = true + break + } else { + out.append(c) + } + } + if (!closed && reportErrors) { + throw RuntimeException("String literal not closed! '${this.str}'") + } + return out.toString() + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/JsonExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/JsonExt.kt new file mode 100644 index 0000000000..f0ee51759b --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/JsonExt.kt @@ -0,0 +1,9 @@ +package korlibs.korge.gradle.util + +import com.google.gson.* + +operator fun JsonElement.get(key: String): JsonElement = asJsonObject.get(key) +val JsonElement.list: JsonArray get() = asJsonArray + +private val prettyGson by lazy { GsonBuilder().setPrettyPrinting().create() } +fun JsonElement.toStringPretty() = prettyGson.toJson(this) diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/KorgeReloadNotifier.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/KorgeReloadNotifier.kt new file mode 100644 index 0000000000..6ca0e86f35 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/KorgeReloadNotifier.kt @@ -0,0 +1,23 @@ +package korlibs.korge.gradle.util + +import java.io.* +import java.net.* + +object KorgeReloadNotifier { + @JvmStatic + fun beforeBuild(timeBeforeCompilationFile: File) { + timeBeforeCompilationFile.writeText("${System.currentTimeMillis()}") + } + + @JvmStatic + fun afterBuild(timeBeforeCompilationFile: File, httpPort: Int) { + val startTime = timeBeforeCompilationFile.readText().toLongOrNull() ?: 0L + val endTime = System.currentTimeMillis() + val url = "http://127.0.0.1:$httpPort/?startTime=$startTime&endTime=$endTime" + try { + println("REPLY FROM KORGE REFRESH: " + URL(url).readText() + " :: $url") + } catch (e: Throwable) { + e.printStackTrace() + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/KotlinVersionExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/KotlinVersionExt.kt new file mode 100644 index 0000000000..95ac99e20b --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/KotlinVersionExt.kt @@ -0,0 +1,6 @@ +package korlibs.korge.gradle.util + +//fun KotlinVersion.Companion.fromString(str: String): KotlinVersion { +// val (major, minor, patch) = str.split(".") + listOf("0", "0", "0") +// return KotlinVersion(major.toInt(), minor.toIntOrNull() ?: 0, patch.toIntOrNull() ?: 0) +//} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/LDLibraries.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/LDLibraries.kt new file mode 100644 index 0000000000..c69b4b87c2 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/LDLibraries.kt @@ -0,0 +1,58 @@ +package korlibs.korge.gradle.util + +import java.io.* +import java.nio.file.Files + +object LDLibraries { + private val libFolders = LinkedHashSet() + private val loadConfFiles = LinkedHashSet() + + val ldFolders: List get() = libFolders.toList() + + // /etc/ld.so.conf + // include /etc/ld.so.conf.d/*.conf + + fun addPath(path: String) { + val file = File(path) + if (file.isDirectory) { + libFolders.add(file) + } + } + + init { + try { + // Fixed paths as described https://renenyffenegger.ch/notes/Linux/fhs/etc/ld_so_conf + addPath("/lib") + addPath("/usr/lib") + // Load config file + loadConfFile(File("/etc/ld.so.conf")) + } catch (e: Throwable) { + e.printStackTrace() + } + } + + fun hasLibrary(name: String) = libFolders.any { File(it, name).exists() } + + private fun loadConfFile(file: File) { + if (file in loadConfFiles) return + loadConfFiles.add(file) + for (line in file.readLines()) { + val tline = line.trim().substringBefore('#').takeIf { it.isNotEmpty() } ?: continue + + if (tline.startsWith("include ")) { + val glob = tline.removePrefix("include ") + val globFolder = File(glob).parentFile + val globPattern = File(glob).name + if (globFolder.isDirectory) { + for (folder in + Files.newDirectoryStream(globFolder.toPath(), globPattern).toList().map { it.toFile() } + ) { + loadConfFile(folder) + } + } + } else { + addPath(tline) + } + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/MD5.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/MD5.kt new file mode 100644 index 0000000000..4d2f2f05b1 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/MD5.kt @@ -0,0 +1,7 @@ +package korlibs.korge.gradle.util + +import java.security.* + +fun ByteArray.md5String(): String { + return MessageDigest.getInstance("MD5").digest(this).hex +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/NamedDomainObjectContainerExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/NamedDomainObjectContainerExt.kt new file mode 100644 index 0000000000..1151e5ea01 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/NamedDomainObjectContainerExt.kt @@ -0,0 +1,10 @@ +package korlibs.korge.gradle.util + +import org.gradle.api.Action +import org.gradle.api.NamedDomainObjectContainer + +fun NamedDomainObjectContainer.createOnce(name: String, configureAction: T.() -> Unit): T { + val item = findByName(name) + if (item != null) return item + return create(name, configureAction) +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/QXml.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/QXml.kt new file mode 100644 index 0000000000..b0a8c413a6 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/QXml.kt @@ -0,0 +1,141 @@ +package korlibs.korge.gradle.util + +import groovy.util.* +import groovy.xml.* +// Why is this required? +import groovy.xml.XmlParser +import groovy.xml.XmlNodePrinter +import java.io.* + +/* +class KorgeXml(val file: File) { + val xmlText = file.readText() + val korge = QXml(xmlParse(xmlText)) + + val name get() = korge["name"].text ?: "untitled" + val description get() = korge["description"].text ?: "" + val orientation get() = korge["orientation"].text ?: "default" + val plugins + get() = korge["plugins"].list.first().children.associate { + it.name to it.attributes + } +} +*/ + +fun NodeList.toFlatNodeList(): List = this.flatMap { + when (it) { + is Node -> listOf(it) + is NodeList -> it.toFlatNodeList() + else -> error("Unsupported it") + } +} + +class QXml private constructor(val nodes: List, dummy: Boolean) : Iterable { + override fun iterator(): Iterator = list.iterator() + + companion object { + operator fun invoke(xml: String): QXml = + QXml(xmlParse(xml)) + operator fun invoke(obj: Node): QXml = + QXml(listOf(obj), true) + operator fun invoke(obj: List): QXml = + QXml(obj, true) + operator fun invoke(obj: List, dummy: Boolean = false): QXml = + QXml(obj.flatMap { it.nodes }) + operator fun invoke(obj: NodeList): QXml = + QXml(obj.toFlatNodeList(), true) + } + + val isEmpty get() = nodes.isEmpty() + val isNotEmpty get() = !isEmpty + + val name: String get() = when (nodes.size) { + 0 -> "null" + 1 -> nodes.first().name().toString() + else -> nodes.toString() + } + + val attributes: Map get() = when { + nodes.isEmpty() -> mapOf() + else -> nodes.map { it.attributes() as Map }.reduce { acc, map -> acc + map } + } + + fun setAttribute(name: String, value: String) { + for (node in nodes) node.attributes()[name] = value + } + + fun setAttributes(vararg pairs: Pair) { + for ((key, value) in pairs) setAttribute(key, value) + } + + val text: String? get() = when (nodes.size) { + 0 -> null + 1 -> (nodes.first() as Node).text() + else -> nodes.map { it.text() }.joinToString("") + } + + val list: List get() = nodes.map { QXml(it) } + + val children: List get() = nodes.flatMap { it.children() as List }.map { + QXml( + it + ) + } + + val parent: QXml get() = QXml(nodes.map { it.parent() }) + + fun remove() { + for (node in nodes) node.parent().remove(node) + } + + fun setValue(value: Any?) { + for (node in nodes) node.setValue(value) + } + + fun appendNode(name: String, attributes: Map): QXml = + QXml(nodes.map { it.appendNode(name, attributes.toMutableMap()) }) + + fun appendNode(name: String, vararg attributes: Pair) = appendNode(name, attributes.toMap().toMutableMap()) + + fun getOrAppendNode(name: String, vararg attributes: Pair): QXml { + return get(name).filter { node -> + attributes.all { node.attributes[it.first] == it.second } + }.takeIf { it.isNotEmpty() }?.let { + QXml(it) + } ?: appendNode(name, *attributes) + } + + operator fun get(key: String): QXml = + QXml(NodeList(nodes.map { it.get(key) })) + + fun serialize(): String = xmlSerialize(nodes.first()) + + override fun toString(): String = "QXml($nodes)" +} + +fun Node?.rchildren(): List { + if (this == null) return listOf() + return this.children() as List +} + +fun Node.getFirstAt(key: String) = getAt(key).firstOrNull() +fun Node.getAt(key: String) = this.getAt(QName(key)) as List +operator fun Node.get(key: String) = this.getAt(QName(key)) + +fun xmlParse(xml: String): Node { + return XmlParser().parseText(xml) +} + +fun xmlSerialize(xml: Node): String { + val sw = StringWriter() + sw.write("\n") + val xnp = XmlNodePrinter(PrintWriter(sw)) + xnp.isNamespaceAware = true + xnp.isPreserveWhitespace = true + xnp.print(xml) + return sw.toString() +} + +fun updateXml(xmlString: String, updater: QXml.() -> Unit): String = QXml( + xmlString +).apply(updater).serialize() diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SFile.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SFile.kt new file mode 100644 index 0000000000..5ad8b6cba9 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SFile.kt @@ -0,0 +1,118 @@ +package korlibs.korge.gradle.util + +import java.io.File + +interface SFile { + val path: String + val nameWithoutExtension: String get() = File(path).nameWithoutExtension + val name: String + val parent: SFile? + fun mkdirs() + fun isDirectory(): Boolean + fun exists(): Boolean + fun write(text: String) + fun read(): String + + fun writeBytes(bytes: ByteArray) + fun readBytes(): ByteArray + + fun list(): List + fun child(name: String): SFile +} + +operator fun SFile.get(path: String): SFile? { + var current: SFile? = this + for (chunk in path.split('/')) { + when (chunk) { + "." -> Unit + ".." -> current = current?.parent + else -> current = current?.child(chunk) + } + } + return current +} + +class LocalSFile(val file: File, val base: File) : SFile { + override fun toString(): String = "LocalSFile($file)" + + constructor(file: File) : this(file, file) + override val path: String by lazy { file.relativeTo(base).toString().replace('\\', '/') } + override val name: String get() = file.name + override val parent: LocalSFile? get() = LocalSFile(file.parentFile, base) + override fun mkdirs() { file.mkdirs() } + override fun isDirectory(): Boolean = file.isDirectory + override fun exists(): Boolean = file.exists() + + override fun write(text: String) = file.writeText(text) + override fun read(): String = file.readText() + + override fun writeBytes(bytes: ByteArray) = file.writeBytes(bytes) + override fun readBytes(): ByteArray = file.readBytes() + + override fun child(name: String): SFile = LocalSFile(File(file, name), base) + override fun list(): List = (file.listFiles() ?: emptyArray()).map { LocalSFile(it, base) } +} + +class MemorySFile(override val name: String, override val parent: MemorySFile? = null) : SFile { + override val path: String by lazy { + when { + parent != null -> "${parent.path}/$name".trim('/') + else -> name + } + } + + var _isDirectory: Boolean = false + var text: String? = null + var bytes: ByteArray? = null + + override fun mkdirs() { + _isDirectory = true + parent?.mkdirs() + } + + override fun isDirectory(): Boolean { + return _isDirectory + } + + override fun exists(): Boolean { + return text != null + } + + override fun write(text: String) { + this.text = text + this.bytes = text.toByteArray() + } + + override fun read(): String { + return text ?: error("File $path doesn't exist") + } + + override fun writeBytes(bytes: ByteArray) { + this.text = "" + this.bytes = bytes + } + + override fun readBytes(): ByteArray = bytes ?: error("File $path doesn't exist") + + private val children: ArrayList = arrayListOf() + private val childrenByName: LinkedHashMap = LinkedHashMap() + override fun child(name: String): SFile { + return childrenByName[name] ?: MemorySFile(name, this).also { + children += it + childrenByName[name] = it + } + } + + override fun list(): List = children.toList() +} + +fun MemorySFile(files: Map): MemorySFile { + val root = MemorySFile("") + for (file in files) { + val rfile = root[file.key] + rfile?.parent?.mkdirs() + rfile?.write(file.value) + } + return root +} +fun MemorySFile(vararg files: Pair) = MemorySFile(files.toMap()) diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Semver.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Semver.kt new file mode 100644 index 0000000000..11d126de2b --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Semver.kt @@ -0,0 +1,25 @@ +package korlibs.korge.gradle.util + +import java.util.* + +data class SemVer(val version: String) : Comparable { + override fun compareTo(other: SemVer): Int = Scanner(this.version).use { s1 -> + Scanner(other.version).use { s2 -> + s1.useDelimiter("\\.") + s2.useDelimiter("\\.") + + while (s1.hasNextInt() && s2.hasNextInt()) { + val v1 = s1.nextInt() + val v2 = s2.nextInt() + when { + v1 < v2 -> return -1 + v1 > v2 -> return +1 + } + } + + if (s1.hasNextInt() && s1.nextInt() != 0) return +1 + return if (s2.hasNextInt() && s2.nextInt() != 0) -1 else 0 + } + } + +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SimpleHttpClient.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SimpleHttpClient.kt new file mode 100644 index 0000000000..4cd6a391a8 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SimpleHttpClient.kt @@ -0,0 +1,73 @@ +package korlibs.korge.gradle.util + +import com.google.gson.* +import com.google.gson.JsonParser +import groovy.json.* +import java.net.* +import java.util.* +import java.util.concurrent.* + +open class SimpleHttpClient( + val user: String? = null, + val pass: String? = null +) { + open fun requestWithRetry(url: String, body: Any? = null, nretries: Int = 15): JsonElement { + var retryCount = 0 + while (true) { + try { + return request(url, body) + } catch (e: SimpleHttpException) { + when (e.responseCode) { + in 500..599 -> { // Sometimes HTTP Error 502 Bad Gateway + e.printStackTrace() + retryCount++ + if (retryCount >= nretries) throw RuntimeException("Couldn't access $url after $nretries retries :: ${e.responseCode} : ${e.message}", e) + println("Retrying... retryCount=$retryCount/$nretries") + Thread.sleep(15_000L + (retryCount * 5_000L)) + continue + } + else -> { + throw e + } + } + } + } + } + + open fun request(url: String, body: Any? = null): JsonElement { + val post = (URL(url).openConnection()) as HttpURLConnection + post.connectTimeout = 300 * 1000 // 300 seconds // 5 minutes + post.readTimeout = 300 * 1000 // 300 seconds // 5 minutes + post.requestMethod = (if (body != null) "POST" else "GET") + if (user != null && pass != null) { + val authBasic = Base64.getEncoder().encodeToString("${user}:${pass}".toByteArray(Charsets.UTF_8)) + post.setRequestProperty("Authorization", "Basic $authBasic") + } + post.setRequestProperty("Accept", "application/json") + if (body != null) { + post.doOutput = true + post.setRequestProperty("Content-Type", "application/json") + val bodyText = if (body is String) body.toString() else JsonOutput.toJson(body) + //println(bodyText) + post.outputStream.write(bodyText.toByteArray(Charsets.UTF_8)) + } + val postRC = post.responseCode + val postMessage = post.responseMessage + //println(postRC) + if (postRC < 400) { + return JsonParser.parseString(post.inputStream.reader(Charsets.UTF_8).readText()) + } else { + val errorString = try { + post.errorStream?.reader(Charsets.UTF_8)?.readText() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + null + } + throw SimpleHttpException(postRC, postMessage, url, errorString) + } + } +} + +class SimpleHttpException(val responseCode: Int, val responseMessage: String, val url: String, val errorString: String?) : + RuntimeException("HTTP Error $responseCode $responseMessage - $url - $errorString") diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SpawnExtension.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SpawnExtension.kt new file mode 100644 index 0000000000..1aa9dd7c41 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SpawnExtension.kt @@ -0,0 +1,29 @@ +package korlibs.korge.gradle.util + +import org.gradle.api.* +import java.io.* + +open class SpawnExtension { + open fun spawn(dir: File, command: List) { + ProcessBuilder(command).inheritIO().redirectErrorStream(true).directory(dir).start() + } + open fun execLogger(projectDir: File, vararg params: String, filter: Process.(line: String) -> String? = { it }) { + println("EXEC: ${params.joinToString(" ")}") + val process = ProcessBuilder(*params).redirectErrorStream(true).directory(projectDir).start() + try { + val reader = process.inputStream.reader() + reader.forEachLine { + filter(process, it)?.let { println(it) } + } + } catch (e: IOException) { + // Steam closed is fine if the filter closed the process + } + process.waitFor() + } + + open fun execOutput(projectDir: File, vararg params: String): String { + return ProcessBuilder(*params).redirectErrorStream(true).directory(projectDir).start().inputStream.readBytes().toString(Charsets.UTF_8) + } +} + +var Project.spawnExt: SpawnExtension by projectExtension { SpawnExtension() } diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/StreamExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/StreamExt.kt new file mode 100644 index 0000000000..7fef13b20c --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/StreamExt.kt @@ -0,0 +1,22 @@ +package korlibs.korge.gradle.util + +import java.io.* + +fun OutputStream.write8(value: Int) { + write(value) +} + +fun OutputStream.write16LE(value: Int) { + write((value ushr 0) and 0xFF) + write((value ushr 8) and 0xFF) +} + +fun OutputStream.write32LE(value: Int) { + write((value ushr 0) and 0xFF) + write((value ushr 8) and 0xFF) + write((value ushr 16) and 0xFF) + write((value ushr 24) and 0xFF) +} +fun OutputStream.writeBytes(bytes: ByteArray) { + write(bytes) +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/StringExt.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/StringExt.kt new file mode 100644 index 0000000000..c0b6a4c77d --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/StringExt.kt @@ -0,0 +1,89 @@ +package korlibs.korge.gradle.util + +internal const val HEX_DIGITS_LOWER = "0123456789abcdef" +internal fun String.escape(unicode: Boolean): String { + val out = StringBuilder(this.length + 16) + for (c in this) { + when (c) { + '\\' -> out.append("\\\\") + '"' -> out.append("\\\"") + '\n' -> out.append("\\n") + '\r' -> out.append("\\r") + '\t' -> out.append("\\t") + else -> when { + !unicode && c in '\u0000'..'\u001f' -> { + out.append("\\x") + out.append(HEX_DIGITS_LOWER[(c.code ushr 4) and 0xF]) + out.append(HEX_DIGITS_LOWER[(c.code ushr 0) and 0xF]) + } + unicode && !c.isPrintable() -> { + out.append("\\u") + out.append(HEX_DIGITS_LOWER[(c.code ushr 12) and 0xF]) + out.append(HEX_DIGITS_LOWER[(c.code ushr 8) and 0xF]) + out.append(HEX_DIGITS_LOWER[(c.code ushr 4) and 0xF]) + out.append(HEX_DIGITS_LOWER[(c.code ushr 0) and 0xF]) + } + else -> out.append(c) + } + } + } + return out.toString() +} +internal fun String.escape(): String = escape(unicode = false) +internal fun String.escapeUnicode(): String = escape(unicode = true) + +internal fun String?.quote(unicode: Boolean): String = if (this != null) "\"${this.escape(unicode)}\"" else "null" +internal fun String?.quote(): String = quote(unicode = false) +internal fun String?.quoteUnicode(): String = quote(unicode = true) + +val String?.quoted: String get() = this.quote() + +internal fun Int.mask(): Int = (1 shl this) - 1 +internal fun Int.extract(offset: Int, count: Int): Int = (this ushr offset) and count.mask() + +fun String.unescape(): String { + val out = StringBuilder(this.length) + var n = 0 + while (n < this.length) { + val c = this[n++] + when (c) { + '\\' -> { + val c2 = this[n++] + when (c2) { + '\\' -> out.append('\\') + '"' -> out.append('\"') + 'n' -> out.append('\n') + 'r' -> out.append('\r') + 't' -> out.append('\t') + 'x', 'u' -> { + val N = if (c2 == 'u') 4 else 2 + val chars = this.substring(n, n + N) + n += N + out.append(chars.toInt(16).toChar()) + } + else -> { + out.append("\\$c2") + } + } + } + else -> out.append(c) + } + } + return out.toString() +} + +fun String.isQuoted(): Boolean = this.startsWith('"') && this.endsWith('"') + +fun String.unquote(): String = when { + isQuoted() -> this.substring(1, this.length - 1).unescape() + else -> this +} + +fun Char.isDigit(): Boolean = this in '0'..'9' +fun Char.isLetter(): Boolean = this in 'a'..'z' || this in 'A'..'Z' +fun Char.isLetterOrDigit(): Boolean = isLetter() || isDigit() +fun Char.isLetterOrUnderscore(): Boolean = this.isLetter() || this == '_' || this == '$' +fun Char.isLetterDigitOrUnderscore(): Boolean = this.isLetterOrDigit() || this == '_' || this == '$' +fun Char.isLetterOrDigitOrDollar(): Boolean = this.isLetterOrDigit() || this == '$' +val Char.isNumeric: Boolean get() = this.isDigit() || this == '.' || this == 'e' || this == '-' +fun Char.isPrintable(): Boolean = this in '\u0020'..'\u007e' || this in '\u00a1'..'\u00ff' diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SystemExec.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SystemExec.kt new file mode 100644 index 0000000000..2c84cda63a --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/SystemExec.kt @@ -0,0 +1,26 @@ +package korlibs.korge.gradle.util + +import java.io.* + +fun executeSystemCommand(command: Array, cwd: File? = null, envs: Map? = null): SystemExecResult { + val output = StringBuilder() + var exitCode = -1 + val processBuilder = ProcessBuilder(*command) + cwd?.let { processBuilder.directory(it) } + envs?.let { processBuilder.environment().putAll(it) } + processBuilder.redirectErrorStream(true) + val process = processBuilder.start() + BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + output.append(line).append("\n") + println(line) + } + exitCode = process.waitFor() + } + return SystemExecResult(exitCode, output.toString()) +} +class SystemExecResult(var exitCode: Int, var stdout: String) { + val success get() = exitCode == 0 + val failed get() = !success +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/TextCase.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/TextCase.kt new file mode 100644 index 0000000000..f7541a45e8 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/TextCase.kt @@ -0,0 +1,45 @@ +package korlibs.korge.gradle.util + +fun String.textCase(): TextCase = TextCase(this) + +class TextCase(val words: List) { + companion object { + operator fun invoke(str: String): TextCase { + // @TODO: + // - hello_World + // - SNAKE_CASE + // - TEST-DEMO + // - helloWorld + // - HelloWorld + return TextCase(str.replace('_', '-').split(Regex("\\W+"))) + //val out = arrayListOf() + //val sr = StrReader(str) + //var lastLowerCase: Boolean? = null + //while (sr.hasMore) { + // val lowerCase = sr.peek().isLowerCase() + // val changedCase = (lowerCase != lastLowerCase) + // if (changedCase) { + // } + // lastLowerCase = lowerCase + //} + //return TextCase(out) + } + } + + fun spaceCase(): String = words.joinToString(" ") { it.lowercase() } + fun snakeCase(): String = words.joinToString("_") { it.lowercase() } + fun kebabCase(): String = words.joinToString("_") { it.lowercase() } + fun screamingSnakeCase(): String = words.joinToString("_") { it.uppercase() } + fun pascalCase(): String = words.joinToString("") { it.lowercase().replaceFirstChar { it.uppercaseChar() } } + fun camelCase(): String { + var first = true + return words.joinToString("") { + if (first) { + first = false + it.lowercase() + } else { + it.lowercase().replaceFirstChar { it.uppercaseChar() } + } + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/UniqueNameGenerator.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/UniqueNameGenerator.kt new file mode 100644 index 0000000000..6456472688 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/UniqueNameGenerator.kt @@ -0,0 +1,16 @@ +package korlibs.korge.gradle.util + +class UniqueNameGenerator { + val nameToSuffix = LinkedHashMap() + + operator fun get(name: String): String { + if (name !in nameToSuffix) { + nameToSuffix[name] = -1 + return name + } else { + val index = nameToSuffix[name]!! + 1 + nameToSuffix[name] = index + return get("$name$index") + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Yaml.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Yaml.kt new file mode 100644 index 0000000000..825f450e06 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/Yaml.kt @@ -0,0 +1,363 @@ +package korlibs.korge.gradle.util + +import kotlin.collections.set + +object Yaml { + fun decode(str: String): Any? = read(ListReader(tokenize(str)), level = 0) + fun read(str: String): Any? = read(ListReader(tokenize(str)), level = 0) + + private fun parseStr(toks: List): Any? { + if (toks.size == 1 && toks[0] is Token.STR) return toks[0].ustr + return parseStr(toks.joinToString("") { it.ustr }) + } + + private fun parseStr(str: String) = when (str) { + "null" -> null + "true" -> true + "false" -> false + else -> str.toIntOrNull() ?: str.toDoubleOrNull() ?: str + } + + //const val TRACE = true + const val TRACE = false + private val EMPTY_SET = setOf() + private val SET_COMMA_END_ARRAY = setOf(",", "]") + + private fun read(s: ListReader, level: Int): Any? = s.run { + var list: ArrayList? = null + var map: MutableMap? = null + var lastMapKey: String? = null + var lastMapValue: Any? = null + + val levelStr = if (TRACE) " ".repeat(level) else "" + + linehandle@ while (s.hasMore) { + val token = s.peek() + val line = token as? Token.LINE + val lineLevel = line?.level + if (TRACE && line != null) println("${levelStr}LINE($lineLevel)") + if (lineLevel != null && lineLevel > level) { + // child level + val res = read(s, lineLevel) + if (list != null) { + if (TRACE) println("${levelStr}CHILD.list.add: $res") + list.add(res) + } else { + if (TRACE) println("${levelStr}CHILD.return: $res") + return res + } + } else if (lineLevel != null && lineLevel < level) { + // parent level + if (TRACE) println("${levelStr}PARENT: level < line.level") + break + } else { + // current level + if (line != null) s.read() + if (s.eof) break + val item = s.peek() + when (item.str) { + "-" -> { + if (s.read().str != "-") invalidOp + if (list == null) { + list = arrayListOf() + if (map != null && lastMapKey != null && lastMapValue == null) { + map[lastMapKey] = list + } + } + if (TRACE) println("${levelStr}LIST_ITEM...") + val res = read(s, level + 1) + if (TRACE) println("${levelStr}LIST_ITEM: $res") + list.add(res) + } + "[" -> { + if (s.read().str != "[") invalidOp + val olist = arrayListOf() + array@ while (s.peek().str != "]") { + olist += readOrString(s, level, SET_COMMA_END_ARRAY, supportNonSpaceSymbols = false) + val p = s.peek().str + when (p) { + "," -> { s.read(); continue@array } + "]" -> break@array + else -> invalidOp("Unexpected '$p'") + } + } + if (s.read().str != "]") invalidOp + return olist + } + else -> { + val keyIds = s.readId() + val sp = s.peekOrNull() ?: Token.EOF + if (s.eof || (sp.str != ":" || (sp is Token.SYMBOL && !sp.isNextWhite))) { + val key = parseStr(keyIds) + if (TRACE) println("${levelStr}LIT: $key") + return key + } else { + val key = parseStr(keyIds).toString() + if (map == null) map = LinkedHashMap() + if (s.read().str != ":") invalidOp + if (TRACE) println("${levelStr}MAP[$key]...") + val next = s.peekOrNull() + val nextStr = next?.str + val hasSpaces = next is Token.SYMBOL && next.isNextWhite + val nextIsSpecialSymbol = nextStr == "[" || nextStr == "{" || (nextStr == "-" && hasSpaces) + val value = readOrString(s, level, EMPTY_SET, supportNonSpaceSymbols = !nextIsSpecialSymbol) + lastMapKey = key + lastMapValue = value + map[key] = value + list = null + if (TRACE) println("${levelStr}MAP[$key]: $value") + } + } + } + } + } + + if (TRACE) println("${levelStr}RETURN: list=$list, map=$map") + + return map ?: list + } + + private fun ListReader.readId(): List { + val tokens = arrayListOf() + while (hasMore) { + val token = peek() + if (token is Token.ID || token is Token.STR || ((token is Token.SYMBOL) && token.str == "-") || ((token is Token.SYMBOL) && token.str == ":" && !token.isNextWhite)) { + tokens.add(token) + read() + } else { + break + } + } + return tokens + } + + private fun readOrString(s: ListReader, level: Int, delimiters: Set, supportNonSpaceSymbols: Boolean): Any? { + val sp = s.peek() + return if (sp is Token.ID || (supportNonSpaceSymbols && sp is Token.SYMBOL && !sp.isNextWhite)) { + var str = "" + str@while (s.hasMore) { + val p = s.peek() + if (p is Token.LINE) break@str + if (p.str in delimiters) break@str + str += s.read().str + } + parseStr(str) + } else { + read(s, level + 1) + } + } + + fun tokenize(str: String): List = StrReader(str.replace("\r\n", "\n")).tokenize() + + private fun StrReader.tokenize(): List { + val out = arrayListOf() + + val s = this + var str = "" + fun flush() { + if (str.isNotBlank() && str.isNotEmpty()) { + out += Token.ID(str.trim()); str = "" + } + } + + val indents = ArrayList() + linestart@ while (hasMore) { + // Line start + flush() + val indentStr = readWhile(kotlin.Char::isWhitespace).replace("\t", " ") + if (indentStr.contains('\n')) continue@linestart // ignore empty lines with possible additional indent + val indent = indentStr.length + if (indents.isEmpty() || indent > indents.last()) { + indents += indent + } else { + while (indents.isNotEmpty() && indent < indents.last()) indents.removeAt(indents.size - 1) + if (indents.isEmpty()) invalidOp + } + val indentLevel = indents.size - 1 + while (out.isNotEmpty() && out.last() is Token.LINE) out.removeAt(out.size - 1) + out += Token.LINE(indentStr, indentLevel) + while (hasMore) { + val c = read() + when (c) { + ':', '-', '[', ']', ',' -> { + flush(); out += Token.SYMBOL("$c", peekChar()) + } + '#' -> { + if (str.lastOrNull()?.isWhitespaceFast() == true || (str == "" && out.lastOrNull() is Token.LINE)) { + flush(); readUntilLineEnd(); skip(); continue@linestart + } else { + str += c + } + } + '\n' -> { + flush(); continue@linestart + } + '"', '\'' -> { + flush() + val last = out.lastOrNull() + //println("out=$last, c='$c', reader=$this") + if (last is Token.SYMBOL && (last.str == ":" || last.str == "[" || last.str == "{" || last.str == "," || last.str == "-")) { + s.unread() + //println(" -> c='$c', reader=$this") + out += Token.STR(s.readStringLit()) + } else { + str += c + } + } + else -> str += c + } + } + } + flush() + return out + } + + interface Token { + val str: String + val ustr get() = str + + object EOF : Token { + override val str: String = "" + } + + data class LINE(override val str: String, val level: Int) : Token { + override fun toString(): String = "LINE($level)" + } + + data class ID(override val str: String) : Token + data class STR(override val str: String) : Token { + override val ustr = str.unquote() + } + + data class SYMBOL(override val str: String, val next: Char) : Token { + val isNextWhite: Boolean get() = next == ' ' || next == '\t' || next == '\n' || next == '\r' + } + } + + private fun StrReader.readUntilLineEnd() = this.readUntil { it == '\n' } + + private val invalidOp: Nothing get() = throw RuntimeException() + private fun invalidOp(msg: String): Nothing = throw RuntimeException(msg) + + private class ListReader(val list: List, val ctx: T? = null) { + class OutOfBoundsException(val list: ListReader<*>, val pos: Int) : RuntimeException() + var position = 0 + val eof: Boolean get() = position >= list.size + val hasMore: Boolean get() = position < list.size + fun peekOrNull(): T? = list.getOrNull(position) + fun peek(): T = list.getOrNull(position) ?: throw OutOfBoundsException(this, position) + fun skip(count: Int = 1) = this.apply { this.position += count } + fun read(): T = peek().apply { skip(1) } + override fun toString(): String = "ListReader($list)" + } + + private class StrReader(val str: String, var pos: Int = 0) { + val length get() = str.length + val hasMore get() = pos < length + + inline fun skipWhile(f: (Char) -> Boolean) { while (hasMore && f(peek())) skip() } + fun skipUntil(f: (Char) -> Boolean): Unit = skipWhile { !f(it) } + + // @TODO: https://youtrack.jetbrains.com/issue/KT-29577 + private fun posSkip(count: Int): Int { + val out = this.pos + this.pos += count + return out + } + + fun skip() = skip(1) + fun peek(): Char = if (hasMore) this.str[this.pos] else '\u0000' + fun peekChar(): Char = peek() + fun read(): Char = if (hasMore) this.str[posSkip(1)] else '\u0000' + fun unread() = skip(-1) + + fun substr(start: Int, len: Int = length - pos): String { + val start = (start).coerceIn(0, length) + val end = (start + len).coerceIn(0, length) + return this.str.substring(start, end) + } + + fun skip(count: Int) = this.apply { this.pos += count } + fun peek(count: Int): String = this.substr(this.pos, count) + fun read(count: Int): String = this.peek(count).also { skip(count) } + + private inline fun readBlock(callback: () -> Unit): String { + val start = pos + callback() + val end = pos + return substr(start, end - start) + } + + fun readWhile(f: (Char) -> Boolean): String = readBlock { skipWhile(f) } + fun readUntil(f: (Char) -> Boolean): String = readBlock { skipUntil(f) } + + fun readStringLit(reportErrors: Boolean = true): String { + val out = StringBuilder() + val quotec = read() + when (quotec) { + '"', '\'' -> Unit + else -> throw RuntimeException("Invalid string literal") + } + var closed = false + while (hasMore) { + val c = read() + if (c == '\\') { + val cc = read() + out.append( + when (cc) { + '\\' -> '\\'; '/' -> '/'; '\'' -> '\''; '"' -> '"' + 'b' -> '\b'; 'f' -> '\u000c'; 'n' -> '\n'; 'r' -> '\r'; 't' -> '\t' + 'u' -> read(4).toInt(0x10).toChar() + else -> throw RuntimeException("Invalid char '$cc'") + } + ) + } else if (c == quotec) { + closed = true + break + } else { + out.append(c) + } + } + if (!closed && reportErrors) { + throw RuntimeException("String literal not closed! '${this.str}'") + } + return out.toString() + } + + override fun toString(): String = "StrReader(str=${str.length}, pos=$pos, range='${str.substring(pos.coerceIn(str.indices), (pos + 10).coerceIn(str.indices)).replace("\n", "\\n")}')" + } + + private fun Char.isWhitespaceFast(): Boolean = this == ' ' || this == '\t' || this == '\r' || this == '\n' + private fun String.isQuoted(): Boolean = this.startsWith('"') && this.endsWith('"') + private fun String.unquote(): String = if (isQuoted()) this.substring(1, this.length - 1).unescape() else this + private fun String.unescape(): String { + val out = StringBuilder(this.length) + var n = 0 + while (n < this.length) { + val c = this[n++] + when (c) { + '\\' -> { + val c2 = this[n++] + when (c2) { + '\\' -> out.append('\\') + '"' -> out.append('\"') + 'n' -> out.append('\n') + 'r' -> out.append('\r') + 't' -> out.append('\t') + 'x', 'u' -> { + val N = if (c2 == 'u') 4 else 2 + val chars = this.substring(n, n + N) + n += N + out.append(chars.toInt(16).toChar()) + } + else -> { + out.append("\\$c2") + } + } + } + else -> out.append(c) + } + } + return out.toString() + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/projectExtension.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/projectExtension.kt new file mode 100644 index 0000000000..efb7c4c1e1 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/korge/gradle/util/projectExtension.kt @@ -0,0 +1,22 @@ +package korlibs.korge.gradle.util + +import org.gradle.api.* +import org.gradle.api.plugins.ExtraPropertiesExtension +import kotlin.reflect.* + +private val Project.ext get() = extensions.getByType(ExtraPropertiesExtension::class.java) + +class projectExtension(val overrideName: String? = null, val gen: Project.() -> T) { + val KProperty<*>.extensionName get() = "extension.${overrideName ?: name}" + + operator fun setValue(project: Project, property: KProperty<*>, value: T) { + project.ext.set(property.extensionName, value) + } + + operator fun getValue(project: Project, property: KProperty<*>): T { + if (!project.ext.has(property.extensionName)) { + project.ext.set(property.extensionName, gen(project)) + } + return project.ext.get(property.extensionName) as T + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/modules/GithubCI.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/GithubCI.kt new file mode 100644 index 0000000000..c64ec2d568 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/GithubCI.kt @@ -0,0 +1,14 @@ +package korlibs.modules + +import java.io.* + +object GithubCI { + fun setOutput(name: String, value: String) { + val GITHUB_OUTPUT = System.getenv("GITHUB_OUTPUT") + if (GITHUB_OUTPUT != null) { + File(GITHUB_OUTPUT).appendText("$name=$value\n") + } else { + println("::set-output name=$name::$value") + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Publishing.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Publishing.kt new file mode 100644 index 0000000000..d3e7b70000 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Publishing.kt @@ -0,0 +1,136 @@ +package korlibs.modules + +import korlibs.korge.gradle.util.* +import korlibs.* +import groovy.util.* +import groovy.xml.* +import org.gradle.api.* +import org.gradle.api.publish.* +import org.gradle.api.publish.maven.* +import org.gradle.jvm.tasks.* + +fun Project.getCustomProp(name: String, default: String): String? { + val props = if (extra.has("props")) extra["props"] as? Map? else null + return props?.get(name) ?: (findProperty(name) as? String?) ?: default +} + +fun Project.configurePublishing(multiplatform: Boolean = true) { + val publishUser = project.sonatypePublishUserNull + val publishPassword = project.sonatypePublishPasswordNull + + plugins.apply("maven-publish") + + val isGradlePlugin = project.name.contains("gradle-plugin") + val sourcesJar = if (!multiplatform && !isGradlePlugin) { + tasks.createThis("sourceJar") { + archiveClassifier.set("sources") + val kotlinJvm = project.extensions.getByType(org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension::class.java) + from(kotlinJvm.sourceSets["main"].kotlin.srcDirs) + } + } else { + null + } + + //tasks.getByName("publishKorgePluginMarkerMavenPublicationToMavenLocal") + + //val emptyJar = tasks.createThis("emptyJar") {} + + publishing.apply { + when { + customMavenUrl != null -> { + repositories { + it.maven { + it.credentials { + it.username = project.customMavenUser + it.password = project.customMavenPass + } + it.url = uri(project.customMavenUrl!!) + } + } + } + publishUser == null || publishPassword == null -> { + doOnce("publishingWarningLogged") { + logger.info("Publishing is not enabled. Was not able to determine either `publishUser` or `publishPassword`") + } + } + else -> { + repositories { + it.maven { + it.credentials { + it.username = publishUser + it.password = publishPassword + } + it.url = when { + version.toString().contains("-SNAPSHOT") -> uri("https://oss.sonatype.org/content/repositories/snapshots/") + project.stagedRepositoryId != null -> uri("https://oss.sonatype.org/service/local/staging/deployByRepositoryId/${project.stagedRepositoryId}/") + else -> uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/") + } + doOnce("showDeployTo") { logger.info("DEPLOY mavenRepository: ${it.url}") } + } + } + } + } + afterEvaluate { + //println(gkotlin.sourceSets.names) + publications.withType(MavenPublication::class.java, Action { publication -> + val jarTaskName = "${publication.name}JavadocJar" + //println(jarTaskName) + val javadocJar = tasks.createThis(jarTaskName) { + archiveClassifier.set("javadoc") + archiveBaseName.set(jarTaskName) + } + val isGradlePluginMarker = publication.name.endsWith("PluginMarkerMaven") + + if (!isGradlePluginMarker && !isGradlePlugin) { + publication.artifact(javadocJar) + } + if (sourcesJar != null) { + publication.artifact(sourcesJar) + } + + //println("PUBLICATION: ${publication.name}") + + //if (multiplatform) { + //if (!isGradlePluginMarker) { + run { + val baseProjectName = project.name.substringBefore('-') + val defaultGitUrl = "https://github.com/korlibs/$baseProjectName" + publication.pom.also { pom -> + pom.name.set(project.name) + pom.description.set(project.description ?: project.getCustomProp("project.description", project.description ?: project.name)) + pom.url.set(project.getCustomProp("project.scm.url", defaultGitUrl)) + pom.licenses { + it.license { + it.name.set(project.getCustomProp("project.license.name", "MIT")) + it.url.set(project.getCustomProp("project.license.url", "https://raw.githubusercontent.com/korlibs/$baseProjectName/master/LICENSE")) + } + } + pom.developers { + it.developer { + it.id.set(project.getCustomProp("project.author.id", "soywiz")) + it.name.set(project.getCustomProp("project.author.name", "Carlos Ballesteros Velasco")) + it.email.set(project.getCustomProp("project.author.email", "soywiz@gmail.com")) + } + } + pom.scm { + it.url.set(project.getCustomProp("project.scm.url", defaultGitUrl)) + } + } + publication.pom.withXml { + if (publication.pom.packaging == "aar") { + //println("baseProjectName=$baseProjectName") + it.asNode().apply { + val nodes: NodeList = this.getAt(QName("dependencies")).getAt("dependency").getAt("scope") + for (node in nodes as List) { + node.setValue("compile") + } + } + } + } + } + }) + } + } +} + +val Project.publishing get() = extensions.getByType() diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Signing.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Signing.kt new file mode 100644 index 0000000000..3e4aa610a1 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Signing.kt @@ -0,0 +1,67 @@ +package korlibs.modules + +import korlibs.* +import org.gradle.api.* +import org.gradle.api.plugins.* +import org.gradle.plugins.signing.* +import org.gradle.plugins.signing.signatory.internal.pgp.* +import org.gradle.plugins.signing.signatory.pgp.* + +private fun ExtraPropertiesExtension.getOrSet(key: String, build: () -> T): T { + if (!has(key)) { + set(key, build()) + } + return get(key) as T +} + +fun Project.configureSigning() { //= doOncePerProject("configureSigningOnce") { +//fun Project.configureSigning() { + //println("configureSigning: $this") + val signingSecretKeyRingFile = System.getenv("ORG_GRADLE_PROJECT_signingSecretKeyRingFile") ?: project.findProperty("signing.secretKeyRingFile")?.toString() + + // gpg --armor --export-secret-keys foobar@example.com | awk 'NR == 1 { print "signing.signingKey=" } 1' ORS='\\n' + val signingKey: String? = System.getenv("ORG_GRADLE_PROJECT_signingKey") ?: project.findProperty("signing.signingKey")?.toString() + val signingPassword: String? = System.getenv("ORG_GRADLE_PROJECT_signingPassword") ?: project.findProperty("signing.password")?.toString() + + if (signingSecretKeyRingFile == null && signingKey == null) { + doOnce("signingWarningLogged") { + logger.info("WARNING! Signing not configured due to missing properties/environment variables like signing.keyId or ORG_GRADLE_PROJECT_signingKey. This is required for deploying to Maven Central. Check README for details") + } + } else { + plugins.apply("signing") + + val signatories = rootProject.extra.getOrSet("signatories") { CachedInMemoryPgpSignatoryProvider(signingKey, signingPassword) } + + //println("signatories=$signatories") + + afterEvaluate { + //println("configuring signing for $this") + signing.apply { + // This might be duplicated for korge-gradle-plugin? : Signing plugin detected. Will automatically sign the published artifacts. + try { + sign(publishing.publications) + } catch (e: GradleException) { + } + if (signingKey != null) { + this.signatories = signatories + //useInMemoryPgpKeys(signingKey, signingPassword) + } + //project.gradle.taskGraph.whenReady {} + } + } + } +} + +open class CachedInMemoryPgpSignatoryProvider(signingKey: String?, signingPassword: String?) : InMemoryPgpSignatoryProvider(signingKey, signingPassword) { + var cachedPhpSignatory: PgpSignatory? = null + override fun getDefaultSignatory(project: Project): PgpSignatory? { + //project.rootProject + //println("getDefaultSignatory:$project") + if (cachedPhpSignatory == null) { + cachedPhpSignatory = super.getDefaultSignatory(project) + } + return cachedPhpSignatory + } +} + +val Project.signing get() = extensions.getByType() diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Sonatype.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Sonatype.kt new file mode 100644 index 0000000000..aea7fe2c1d --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Sonatype.kt @@ -0,0 +1,237 @@ +package korlibs.modules + +// https://central.sonatype.org/publish/publish-guide/#deployment + +import com.google.gson.* +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import java.io.* + +val Project.customMavenUser: String? get() = System.getenv("KORLIBS_CUSTOM_MAVEN_USER") ?: rootProject.findProperty("KORLIBS_CUSTOM_MAVEN_USER")?.toString() +val Project.customMavenPass: String? get() = System.getenv("KORLIBS_CUSTOM_MAVEN_PASS") ?: rootProject.findProperty("KORLIBS_CUSTOM_MAVEN_PASS")?.toString() +val Project.customMavenUrl: String? get() = System.getenv("KORLIBS_CUSTOM_MAVEN_URL") ?: rootProject.findProperty("KORLIBS_CUSTOM_MAVEN_URL")?.toString() +val Project.stagedRepositoryId: String? get() = + System.getenv("stagedRepositoryId") + ?: rootProject.findProperty("stagedRepositoryId")?.toString() + ?: File("stagedRepositoryId").takeIfExists()?.readText() + + +val Project.sonatypePublishUserNull: String? get() = (System.getenv("SONATYPE_USERNAME") ?: rootProject.findProperty("SONATYPE_USERNAME")?.toString() ?: project.findProperty("sonatypeUsername")?.toString()) +val Project.sonatypePublishPasswordNull: String? get() = (System.getenv("SONATYPE_PASSWORD") ?: rootProject.findProperty("SONATYPE_PASSWORD")?.toString() ?: project.findProperty("sonatypePassword")?.toString()) + +val Project.sonatypePublishUser get() = sonatypePublishUserNull ?: error("Can't get SONATYPE_USERNAME/sonatypeUsername") +val Project.sonatypePublishPassword get() = sonatypePublishPasswordNull ?: error("Can't get SONATYPE_PASSWORD/sonatypePassword") + +fun Project.configureMavenCentralRelease() { + if (rootProject.tasks.findByName("releaseMavenCentral") == null) { + rootProject.tasks.createThis("releaseMavenCentral").also { task -> + task.doLast { + if (!Sonatype.fromProject(rootProject).releaseRepositoryID(rootProject.stagedRepositoryId)) { + error("Can't promote artifacts. Check log for details") + } + } + } + } + + if (rootProject.tasks.findByName("checkReleasingMavenCentral") == null) { + rootProject.tasks.createThis("checkReleasingMavenCentral").also { task -> + task.doLast { + println("stagedRepositoryId=${rootProject.stagedRepositoryId}") + if (rootProject.stagedRepositoryId.isNullOrEmpty()) { + error("Couldn't find 'stagedRepositoryId' aborting...") + } + } + } + } + if (rootProject.tasks.findByName("startReleasingMavenCentral") == null) { + rootProject.tasks.createThis("startReleasingMavenCentral").also { task -> + task.doLast { + val sonatype = Sonatype.fromProject(rootProject) + val profileId = sonatype.findProfileIdByGroupId("com.soywiz") + val stagedRepositoryId = sonatype.startStagedRepository(profileId) + println("profileId=$profileId") + println("stagedRepositoryId=$stagedRepositoryId") + GithubCI.setOutput("stagedRepositoryId", stagedRepositoryId) + File("stagedRepositoryId").writeText(stagedRepositoryId) + } + } + } +} + + +open class Sonatype( + val user: String, + val pass: String, + val BASE: String = DEFAULT_BASE +) { + companion object { + val DEFAULT_BASE = "https://oss.sonatype.org/service/local/staging" + private val BASE = DEFAULT_BASE + + //fun fromGlobalConfig(): Sonatype { + // val props = Properties().also { it.load(File(System.getProperty("user.home") + "/.gradle/gradle.properties").readText().reader()) } + // return Sonatype(props["sonatypeUsername"].toString(), props["sonatypePassword"].toString(), DEFAULT_BASE) + //} + + fun fromProject(project: Project): Sonatype { + return Sonatype(project.sonatypePublishUser, project.sonatypePublishPassword) + } + + //@JvmStatic + //fun main(args: Array) { + // val sonatype = fromGlobalConfig() + // sonatype.releaseGroupId("korlibs") + //} + } + + fun releaseGroupId(groupId: String = "korlibs"): Boolean { + println("Trying to release groupId=$groupId") + val profileId = findProfileIdByGroupId(groupId) + println("Determined profileId=$profileId") + val repositoryIds = findProfileRepositories(profileId) + if (repositoryIds.isEmpty()) { + println("Can't find any repositories for profileId=$profileId for groupId=$groupId. Artifacts weren't upload?") + return false + } + return releaseRepositoryIDs(repositoryIds) + } + + fun releaseRepositoryID(repositoryId: String?): Boolean { + val repositoryIds = listOfNotNull(repositoryId) + if (repositoryIds.isEmpty()) return false + return releaseRepositoryIDs(repositoryIds) + } + + fun releaseRepositoryIDs(repositoryIds: List): Boolean { + val repositoryIds = repositoryIds.toMutableList() + val totalRepositories = repositoryIds.size + var promoted = 0 + var stepCount = 0 + var retryCount = 0 + process@while (true) { + stepCount++ + if (stepCount > 200) { + error("Too much steps. stepCount=$stepCount") + } + repo@for (repositoryId in repositoryIds.toList()) { + val state = try { + getRepositoryState(repositoryId) + } catch (e: SimpleHttpException) { + when (e.responseCode) { + 404 -> { + println("Can't find $repositoryId anymore. Probably released. Stopping") + repositoryIds.remove(repositoryId) + continue@repo + } + else -> throw e + } + } + when { + state.transitioning -> { + println("Waiting transition $state") + } + // Even if open, if there are notifications we should drop it + state.notifications > 0 -> { + println("Dropping release because of error state.notifications=$state") + println(" - activity: " + getRepositoryActivity(repositoryId)) + repositoryDrop(repositoryId) + repositoryIds.remove(repositoryId) + } + state.isOpen -> { + println("Closing open repository $state") + println(" - activity: " + getRepositoryActivity(repositoryId)) + repositoryClose(repositoryId) + } + else -> { + println("Promoting repository $state") + println(" - activity: " + getRepositoryActivity(repositoryId)) + repositoryPromote(repositoryId) + promoted++ + } + } + } + if (repositoryIds.isEmpty()) { + println("Completed promoted=$promoted, totalRepositories=$totalRepositories, retryCount=$retryCount") + break@process + } + Thread.sleep(30_000L) + } + + return promoted == totalRepositories + } + + private val client get() = SimpleHttpClient(user, pass) + + fun getRepositoryState(repositoryId: String): RepoState { + val info = client.requestWithRetry("${BASE}/repository/$repositoryId") + //println("info: ${info.toStringPretty()}") + return RepoState( + repositoryId = repositoryId, + type = info["type"].asString, + notifications = info["notifications"].asInt, + transitioning = info["transitioning"].asBoolean, + ) + } + + fun getRepositoryActivity(repositoryId: String): String { + val info = client.requestWithRetry("${BASE}/repository/$repositoryId/activity") + //println("info: ${info.toStringPretty()}") + return info.toStringPretty() + } + + data class RepoState( + val repositoryId: String, + // "open" or "closed" + val type: String, + val notifications: Int, + val transitioning: Boolean + ) { + val isOpen get() = type == "open" + } + + private fun getDataMapForRepository(repositoryId: String): Map> { + return mapOf( + "data" to mapOf( + "stagedRepositoryIds" to listOf(repositoryId), + "description" to "", + "autoDropAfterRelease" to true, + ) + ) + } + + fun repositoryClose(repositoryId: String) { + client.requestWithRetry("${BASE}/bulk/close", getDataMapForRepository(repositoryId)) + } + + fun repositoryPromote(repositoryId: String) { + client.requestWithRetry("${BASE}/bulk/promote", getDataMapForRepository(repositoryId)) + } + + fun repositoryDrop(repositoryId: String) { + client.requestWithRetry("${BASE}/bulk/drop", getDataMapForRepository(repositoryId)) + } + + fun findProfileRepositories(profileId: String): List { + return client.requestWithRetry("${BASE}/profile_repositories")["data"].list + .filter { it["profileId"].asString == profileId } + .map { it["repositoryId"].asString } + } + + fun findProfileIdByGroupId(groupId: String): String { + val profiles = client.requestWithRetry("$BASE/profiles")["data"].list + return profiles + .filter { groupId.startsWith(it["name"].asString) } + .map { it["id"].asString } + .firstOrNull() ?: error("Can't find profile with group id '$groupId'") + } + + fun startStagedRepository(profileId: String): String { + return client.requestWithRetry("${BASE}/profiles/$profileId/start", mapOf( + "data" to mapOf("description" to "Explicitly created by easy-kotlin-mpp-gradle-plugin") + ))["data"]["stagedRepositoryId"].asString + } + + operator fun JsonElement.get(key: String): JsonElement = asJsonObject.get(key) + val JsonElement.list: JsonArray get() = asJsonArray + fun JsonElement.toStringPretty() = GsonBuilder().setPrettyPrinting().create().toJson(this) +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Targets.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Targets.kt new file mode 100644 index 0000000000..b06caac4a6 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Targets.kt @@ -0,0 +1,38 @@ +package korlibs.modules + +import org.gradle.api.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.desktop.* +import org.jetbrains.kotlin.gradle.plugin.mpp.* + + +val Project.doEnableKotlinAndroid: Boolean get() = rootProject.findProperty("enableKotlinAndroid") == "true" && System.getenv("DISABLE_KOTLIN_ANDROID") != "true" +val Project.doEnableKotlinMobile: Boolean get() = supportKotlinNative && rootProject.findProperty("enableKotlinMobile") == "true" +val Project.doEnableKotlinMobileTvos: Boolean get() = doEnableKotlinMobile && rootProject.findProperty("enableKotlinMobileTvos") == "true" + +val Project.hasAndroid get() = extensions.findByName("android") != null + +fun org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithPresetFunctions.desktopTargets(project: Project): List { + if (!supportKotlinNative) return listOf() + + val out = arrayListOf() + out.addAll(listOf(linuxX64(), linuxArm64())) + out.addAll(listOf(mingwX64())) + out.addAll(listOf(macosX64(), macosArm64())) + return out +} + +fun org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithPresetFunctions.mobileTargets(project: Project): List { + if (!project.doEnableKotlinMobile) return listOf() + + val out = arrayListOf() + out.addAll(listOf(iosArm64(), iosX64(), iosSimulatorArm64())) + if (project.doEnableKotlinMobileTvos) { + out.addAll(listOf(tvosArm64(), tvosX64(), tvosSimulatorArm64())) + } + return out +} + +fun org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithPresetFunctions.allNativeTargets(project: Project): List { + return mobileTargets(project) +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Tests.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Tests.kt new file mode 100644 index 0000000000..ad0cb03e82 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/Tests.kt @@ -0,0 +1,22 @@ +package korlibs.modules + +import korlibs.* +import org.gradle.api.* +import org.gradle.api.tasks.testing.logging.* + +fun Project.configureTests() { + tasks.withType(org.gradle.api.tasks.testing.AbstractTestTask::class.java).allThis { + testLogging { + //setEvents(setOf("passed", "skipped", "failed", "standardOut", "standardError")) + it.events = mutableSetOf( + //TestLogEvent.STARTED, TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.FAILED, + TestLogEvent.STANDARD_OUT, TestLogEvent.STANDARD_ERROR + ) + it.exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + it.showStandardStreams = true + it.showStackTraces = true + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/modules/WindowsMesaOpenglPatch.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/WindowsMesaOpenglPatch.kt new file mode 100644 index 0000000000..d7f3760e29 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/modules/WindowsMesaOpenglPatch.kt @@ -0,0 +1,23 @@ +package korlibs.modules + +import korlibs.korge.gradle.util.* +import org.gradle.api.* +import org.gradle.api.tasks.* +import org.jetbrains.kotlin.gradle.tasks.* +import java.io.* +import java.net.* + +val Project.localOpengl32X64ZipFile: File get() = File(rootProject.buildDir, "opengl32-x64.zip") + +fun Project.downloadOpenglMesaForWindows(): Task { + if (rootProject.tasks.findByName("downloadOpenglMesaForWindows") == null) { + rootProject.tasks.createThis("downloadOpenglMesaForWindows") { + onlyIf { !localOpengl32X64ZipFile.exists() } + doLast { + val url = URL("https://github.com/korlibs/mesa-dist-win/releases/download/21.2.3/opengl32-x64.zip") + localOpengl32X64ZipFile.writeBytes(url.readBytes()) + } + } + } + return rootProject.tasks.findByName("downloadOpenglMesaForWindows")!! +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/root/ConfigureKover.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/root/ConfigureKover.kt new file mode 100644 index 0000000000..5e2fec51ae --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/root/ConfigureKover.kt @@ -0,0 +1,37 @@ +package korlibs.root + +import korlibs.korge.gradle.util.* +import korlibs.* +import org.gradle.api.* +import org.gradle.api.tasks.testing.* + +fun Project.configureKover() { + rootProject.allprojectsThis { + plugins.apply(kotlinx.kover.KoverPlugin::class.java) + } + + rootProject.koverMerged { + it.enable() + } + + // https://repo.maven.apache.org/maven2/org/jetbrains/intellij/deps/intellij-coverage-agent/1.0.688/ + //val koverVersion = "1.0.688" + val koverVersion = rootProject._libs["versions"]["kover"]["agent"].dynamicInvoke("get").casted() + + rootProject.allprojectsThis { + kover { + it.engine.set(kotlinx.kover.api.IntellijEngine(koverVersion)) + } + extensions.getByType(kotlinx.kover.api.KoverProjectConfig::class.java).apply { + engine.set(kotlinx.kover.api.IntellijEngine(koverVersion)) + } + tasks.withType { + extensions.configure { + //generateXml = false + //generateHtml = true + //coverageEngine = kotlinx.kover.api.CoverageEngine.INTELLIJ + excludes.add(".*BuildConfig") + } + } + } +} diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/root/RootKorlibsPlugin.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/root/RootKorlibsPlugin.kt new file mode 100644 index 0000000000..a3134c767b --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/root/RootKorlibsPlugin.kt @@ -0,0 +1,885 @@ +package korlibs.root + +import korlibs.* +import korlibs.korge.gradle.* +import korlibs.korge.gradle.module.* +import korlibs.korge.gradle.targets.* +import korlibs.korge.gradle.targets.all.* +import korlibs.korge.gradle.targets.android.* +import korlibs.korge.gradle.targets.ios.* +import korlibs.korge.gradle.targets.js.* +import korlibs.korge.gradle.targets.jvm.* +import korlibs.korge.gradle.targets.native.* +import korlibs.korge.gradle.targets.wasm.* +import korlibs.korge.gradle.util.* +import korlibs.korge.gradle.util.create +import korlibs.kotlin +import korlibs.modules.* +import org.gradle.api.* +import org.gradle.api.file.* +import org.gradle.api.tasks.* +import org.gradle.api.tasks.testing.* +import org.jetbrains.dokka.gradle.* +import org.jetbrains.kotlin.gradle.plugin.mpp.* +import org.jetbrains.kotlin.gradle.targets.js.npm.* +import org.jetbrains.kotlin.gradle.targets.js.testing.* +import org.jetbrains.kotlin.gradle.targets.js.testing.karma.* +import org.jetbrains.kotlin.gradle.targets.js.testing.mocha.* +import org.jetbrains.kotlin.gradle.tasks.* +import java.io.* +import java.nio.file.* +import kotlin.io.path.* + +object RootKorlibsPlugin { + val KORGE_GROUP = "com.soywiz.korge" + val KORGE_RELOAD_AGENT_GROUP = "com.soywiz.korge" + val KORGE_GRADLE_PLUGIN_GROUP = "com.soywiz.korlibs.korge.plugins" + + @JvmStatic + fun doInit(rootProject: Project) { + rootProject.init() + rootProject.afterEvaluate { + rootProject.allprojectsThis { + tasks.withType(Test::class.java) { + //it.ignoreFailures = true // This would cause the test to pass even if we have failing tests! + } + } + } + } + + fun Project.init() { + plugins.apply(DokkaPlugin::class.java) + //plugins.apply("js-plain-objects") + + allprojects { + tasks.withType(AbstractDokkaTask::class.java).configureEach { + //println("DOKKA=$it") + it.offlineMode.set(true) + } + } + + checkMinimumJavaVersion() + configureBuildScriptClasspathTasks() + initPlugins() + initRootKotlinJvmTarget() + initVersions() + initAllRepositories() + configureIdea() + initGroupOverrides() + initNodeJSFixes() + configureMavenCentralRelease() + initDuplicatesStrategy() + initSymlinkTrees() + initShowSystemInfoWhenLinkingInWindows() + korlibs.korge.gradle.KorgeVersionsTask.registerShowKorgeVersions(project) + initInstallAndCheckLinuxLibs() + // Disabled by default, since it resolves configurations at configuration time + if (System.getenv("ENABLE_KOVER") == "true") configureKover() + initPublishing() + initKMM() + initShortcuts() + initTests() + initCrossTests() + initAllTargets() + initSamples() + } + + fun Project.initAllTargets() { + rootProject.afterEvaluate { + rootProject.rootEnableFeaturesOnAllTargets() + } + } + + fun Project.initRootKotlinJvmTarget() { + // Required by RC + kotlin { + // Forced Java8 toolchain + //jvmToolchain { (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of("8")) } + jvm() + } + } + + fun Project.initVersions() { + allprojectsThis { + project.version = getProjectForcedVersion() + } + } + + fun Project.initAllRepositories() { + allprojectsThis { + configureRepositories() + } + } + + fun Project.initGroupOverrides() { + allprojectsThis { + val projectName = project.name + val firstComponent = projectName.substringBefore('-') + group = RootKorlibsPlugin.KORGE_GROUP + } + } + + fun Project.initNodeJSFixes() { + plugins.applyOnce() + rootProject.plugins.withType(org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin::class.java, Action { + rootProject.the().nodeVersion = project.nodeVersion + }) + // https://youtrack.jetbrains.com/issue/KT-48273 + afterEvaluate { + rootProject.extensions.configure(org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension::class.java, Action { + //it.versions.webpackDevServer.version = "4.0.0" + }) + } + } + + fun Project.initDuplicatesStrategy() { + allprojectsThis { + tasks.withType(Copy::class.java).allThis { + //this.duplicatesStrategy = org.gradle.api.file.DuplicatesStrategy.WARN + this.duplicatesStrategy = org.gradle.api.file.DuplicatesStrategy.EXCLUDE + //println("Task $this") + } + } + } + + fun Project.initSymlinkTrees() { + } + + fun Project.initShowSystemInfoWhenLinkingInWindows() { + fun Task.configureGCAndSystemInfo() { + val task = this + task.doFirst { + execThis { commandLine("systeminfo") } + println("jcmd -l; jcmd 0 GC.heap_info; jcmd 0 GC.run") + execThis { commandLine("jcmd", "-l") } + execThis { commandLine("jcmd", "0", "GC.heap_info") } + repeat(5) { execThis { commandLine("jcmd", "0", "GC.run") } } + execThis { commandLine("systeminfo") } + } + task.doLast { execThis { commandLine("systeminfo") } } + } + } + + fun Project.initInstallAndCheckLinuxLibs() { + // Install required libraries in Linux with APT + if ( + org.apache.tools.ant.taskdefs.condition.Os.isFamily(org.apache.tools.ant.taskdefs.condition.Os.FAMILY_UNIX) && + (File("/.dockerenv").exists() || System.getenv("TRAVIS") != null || System.getenv("GITHUB_REPOSITORY") != null) && + (File("/usr/bin/apt-get").exists()) && + (!(File("/usr/include/GL/glut.h").exists()) || !(File("/usr/include/AL/al.h").exists())) + ) { + rootProject.execThis { commandLine("sudo", "apt-get", "update") } + rootProject.execThis { commandLine("sudo", "apt-get", "-y", "install", "freeglut3") } + // execThis { commandLine("sudo", "apt-get", "-y", "install", "libgtk-3-dev") } + } + if (isLinux) { + project.logger.info("LD folders: ${LDLibraries.ldFolders}") + for (lib in listOf("libGL.so.1")) { + if (!LDLibraries.hasLibrary(lib)) { + System.err.println("Can't find $lib. Please: sudo apt-get -y install freeglut3") + } + } + } + } + + fun Project.initPlugins() { + plugins.apply("java") + plugins.apply("kotlin-multiplatform") + plugins.apply("signing") + plugins.apply("maven-publish") + } + + fun Project.initPublishing() { + rootProject.afterEvaluate { + rootProject.nonSamples { + if (this.project.isKorgeBenchmarks) return@nonSamples + + plugins.apply("maven-publish") + + val doConfigure = mustAutoconfigureKMM() + + if (doConfigure) { + configurePublishing() + configureSigning() + } + } + } + } + + fun Project.initKMM() { + rootProject.subprojectsThis { + val doConfigure = mustAutoconfigureKMM() + + if (doConfigure) { + val isSample = project.isSample + val hasAndroid = doEnableKotlinAndroid && hasAndroidSdk && project.name != "korge-benchmarks" + //val hasAndroid = !isSample && true + val mustPublish = !isSample + + // AppData\Local\Android\Sdk\tools\bin>sdkmanager --licenses + plugins.apply("kotlin-multiplatform") + //plugins.apply(JsPlainObjectsKotlinGradleSubplugin::class.java) + + //initAndroidProject() + if (hasAndroid) { + project.configureAndroidDirect(ProjectType.fromExecutable(isSample), isKorge = false) + } + + if (isSample && supportKotlinNative && isMacos) { + project.configureNativeIos(projectType = ProjectType.EXECUTABLE) + } + + if (!isSample && rootProject.plugins.hasPlugin("org.jetbrains.dokka")) { + plugins.apply("org.jetbrains.dokka") + } + + if (mustPublish) { + plugins.apply("maven-publish") + } + + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).configureEach { + it.kotlinOptions.suppressWarnings = true + } + + afterEvaluate { + val jvmTest = tasks.findByName("jvmTest") + if (jvmTest is org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest) { + val jvmTestFix = tasks.createThis("jvmTestFix") { + group = "verification" + environment("UPDATE_TEST_REF", "true") + testClassesDirs = jvmTest.testClassesDirs + classpath = jvmTest.classpath + bootstrapClasspath = jvmTest.bootstrapClasspath + if (!JvmAddOpens.beforeJava9) jvmArgs(*JvmAddOpens.createAddOpensTypedArray()) + if (headlessTests) systemProperty("java.awt.headless", "true") + } + val jvmTestInteractive = tasks.createThis("jvmTestInteractive") { + group = "verification" + environment("INTERACTIVE_SCREENSHOT", "true") + testClassesDirs = jvmTest.testClassesDirs + classpath = jvmTest.classpath + bootstrapClasspath = jvmTest.bootstrapClasspath + if (!JvmAddOpens.beforeJava9) jvmArgs(*JvmAddOpens.createAddOpensTypedArray()) + } + if (!JvmAddOpens.beforeJava9) jvmTest.jvmArgs(*JvmAddOpens.createAddOpensTypedArray()) + if (headlessTests) jvmTest.systemProperty("java.awt.headless", "true") + } + } + + kotlin { + //explicitApi() + //explicitApiWarning() + + metadata { + compilations.allThis { + kotlinOptions.suppressWarnings = true + } + } + jvm { + compilations.allThis { + kotlinOptions.jvmTarget = GRADLE_JAVA_VERSION_STR + //kotlinOptions.freeCompilerArgs.add("-Xno-param-assertions") + //kotlinOptions. + + // @TODO: + // Tested on Kotlin 1.4.30: + // Class org.luaj.vm2.WeakTableTest.WeakKeyTableTest + // java.lang.AssertionError: expected: but was: + //kotlinOptions.useIR = true + } + AddFreeCompilerArgs.addFreeCompilerArgs(project, this) + } + if (isWasmEnabled(project)) { + configureWasmTarget(executable = false) + val wasmBrowserTest = tasks.getByName("wasmJsBrowserTest") as KotlinJsTest + // ~/projects/korge/build/js/packages/korge-root-klock-wasm-test + wasmBrowserTest.doFirst { + logger.info("!!!!! wasmBrowserTest PATCH :: $wasmBrowserTest : ${wasmBrowserTest::class.java}") + + val npmProjectDir: File = wasmBrowserTest.compilation.npmProject.dir.get().asFile + val projectName = npmProjectDir.name + val uninstantiatedMjs = File(npmProjectDir, "kotlin/$projectName.uninstantiated.mjs") + + logger.info("# Updating: $uninstantiatedMjs") + + try { + uninstantiatedMjs.writeText(uninstantiatedMjs.readText().replace( + "'kotlin.test.jsThrow' : (jsException) => { throw e },", + "'kotlin.test.jsThrow' : (jsException) => { throw jsException },", + )) + } catch (e: Throwable) { + e.printStackTrace() + } + } + } + js(org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType.IR) { + browser { + compilations.allThis { + //kotlinOptions.sourceMap = true + } + } + configureJsTargetOnce() + configureJSTestsOnce() + } + //configureJSTests() + + tasks.withType(KotlinJsTest::class.java).configureEach { + it.onTestFrameworkSet { framework -> + //println("onTestFrameworkSet: $it : $framework") + when (framework) { + is KotlinMocha -> { + framework.timeout = "20s" + } + is KotlinKarma -> { + File(rootProject.rootDir, "karma.config.d").takeIfExists()?.let { + //println(" -> $it") + framework.useConfigDirectory(it) + //println(" ") + } + } + } + } + } + + val desktopAndMobileTargets = ArrayList().apply { + if (doEnableKotlinMobile) addAll(mobileTargets(project)) + }.toList() + + for (target in desktopAndMobileTargets) { + target.configureKotlinNativeTarget(project) + } + + // common + // js + // concurrent // non-js + // jvmAndroid + // android + // jvm + // native + // kotlin-native + // nonNative: [js, jvmAndroid] + sourceSets.apply { + + val common = createPairSourceSet("common", project = project) { test -> + dependencies { + if (test) { + implementation(kotlin("test-common")) + implementation(kotlin("test-annotations-common")) + } else { + implementation(kotlin("stdlib-common")) + } + } + } + + val concurrent = createPairSourceSet("concurrent", common, project = project) + val jvmAndroid = createPairSourceSet("jvmAndroid", concurrent, project = project) + + // Default source set for JVM-specific sources and dependencies: + // JVM-specific tests and their dependencies: + val jvm = createPairSourceSet("jvm", jvmAndroid, project = project) { test -> + dependencies { + if (test) { + implementation(kotlin("test-junit")) + } else { + implementation(kotlin("stdlib-jdk8")) + } + } + } + + if (hasAndroid) { + val android = createPairSourceSet("android", jvmAndroid, doTest = false, project = project) { test -> + dependencies { + if (test) { + //implementation(kotlin("test")) + //implementation(kotlin("test-junit")) + implementation(kotlin("test-junit")) + } else { + //implementation(kotlin("stdlib")) + //implementation(kotlin("stdlib-jdk8")) + } + } + } + } + + val js = createPairSourceSet("js", common, project = project) { test -> + dependencies { + if (test) { + implementation(kotlin("test-js")) + } else { + implementation(kotlin("stdlib-js")) + } + } + } + + if (isWasmEnabled(project)) { + val wasm = createPairSourceSet("wasmJs", common, project = project) { test -> + dependencies { + if (test) { + implementation(kotlin("test-wasm-js")) + } else { + implementation(kotlin("stdlib-wasm-js")) + } + } + } + } + + if (supportKotlinNative) { + //val iosTvosMacos by lazy { createPairSourceSet("iosTvosMacos", darwin) } + //val iosMacos by lazy { createPairSourceSet("iosMacos", iosTvosMacos) } + + val native by lazy { createPairSourceSet("native", concurrent, project = project) } + val posix by lazy { createPairSourceSet("posix", native, project = project) } + val apple by lazy { createPairSourceSet("apple", posix, project = project) } + val darwin by lazy { createPairSourceSet("darwin", apple, project = project) } + val darwinMobile by lazy { createPairSourceSet("darwinMobile", darwin, project = project) } + val iosTvos by lazy { createPairSourceSet("iosTvos", darwinMobile/*, iosTvosMacos*/, project = project) } + val tvos by lazy { createPairSourceSet("tvos", iosTvos, project = project) } + val ios by lazy { createPairSourceSet("ios", iosTvos/*, iosMacos*/, project = project) } + + for (target in mobileTargets(project)) { + val native = createPairSourceSet(target.name, project = project) + when { + target.isIos -> native.dependsOn(ios) + target.isTvos -> native.dependsOn(tvos) + } + } + + // Copy test resources + afterEvaluate { + for (targetV in (listOf(iosX64(), iosSimulatorArm64()))) { + val target = targetV.name + val taskName = "copyResourcesToExecutable_$target" + val targetTestTask = tasks.findByName("${target}Test") as? org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest? ?: continue + val compileTestTask = tasks.findByName("compileTestKotlin${target.capitalize()}") ?: continue + val compileMainTask = tasks.findByName("compileKotlin${target.capitalize()}") ?: continue + + //println("$targetTestTask -> $target") + + tasks { + create(taskName) { + for (sourceSet in kotlin.sourceSets) { + from(sourceSet.resources) + } + + into(targetTestTask.executable.parentFile) + } + } + + targetTestTask.inputs.files( + *compileTestTask.outputs.files.files.toTypedArray(), + *compileMainTask.outputs.files.files.toTypedArray() + ) + + targetTestTask.dependsOn(taskName) + //println(".target=$target") + } + } + } + } + } + } + project.afterEvaluate { + project.addGenResourcesTasks() + } + } + } + + fun Project.initSamples() { + rootProject.samples { + if (isWasmEnabled(project)) { + configureWasm(ProjectType.EXECUTABLE, binaryen = false) + } + + // @TODO: Move to KorGE plugin + project.configureJvmRunJvm(isRootKorlibs = true) + project.apply { + + project.tasks { + if (!isWindows) { + afterEvaluate { + for (type in CrossExecType.VALID_LIST) { + for (deb in listOf("Debug", "Release")) { + val linkTask = project.tasks.findByName("link${deb}Executable${type.nameWithArchCapital}") as? KotlinNativeLink? ?: continue + tasks.createThis("runNative${deb}${type.interpCapital}") { + group = "run" + dependsOn(linkTask) + val result = commandLineCross(linkTask.binary.outputFile.absolutePath, type = type) + doFirst { + result.ensure() + } + this.environment("WINEDEBUG", "-all") + workingDir = linkTask.binary.outputDirectory + } + } + } + } + } + + //val jsRun = createThis("jsRun") { dependsOn("jsBrowserDevelopmentRun") } // Already available + //val jvmRun = createThis("jvmRun") { + // group = "run" + // dependsOn(runJvm) + //} + //val run by getting(JavaExec::class) + + //val processResources by getting { + // dependsOn(processResourcesKorge) + //} + } + } + + kotlin { + jvm { + } + js(org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType.IR) { + browser { + binaries.executable() + } + configureJsTargetOnce() + } + + tasks.getByName("jsProcessResources").apply { + //println(this.outputs.files.toList()) + doLast { + val targetDir = this.outputs.files.first() + val jsMainCompilation: KotlinJsCompilation = kotlin.js(org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType.IR).compilations.findByName("main")!! + + // @TODO: How to get the actual .js file generated/served? + val jsFile = File("${project.name}.js").name + val resourcesFolders = jsMainCompilation.allKotlinSourceSets + .flatMap { it.resources.srcDirs } + listOf( + File(rootProject.rootDir, "_template"), + File(rootProject.rootDir, "buildSrc/src/main/resources"), + ) + //println("jsFile: $jsFile") + //println("resourcesFolders: $resourcesFolders") + fun readTextFile(name: String): String { + for (folder in resourcesFolders) { + val file = File(folder, name)?.takeIf { it.exists() } ?: continue + return file.readText() + } + return ClassLoader.getSystemResourceAsStream(name)?.readBytes()?.toString(Charsets.UTF_8) + ?: error("We cannot find suitable '$name'") + } + + val indexTemplateHtml = readTextFile("index.v2.template.html") + val customCss = readTextFile("custom-styles.template.css") + val customHtmlHead = readTextFile("custom-html-head.template.html") + val customHtmlBody = readTextFile("custom-html-body.template.html") + + //println(File(targetDir, "index.html")) + + File(targetDir, "index.html").writeText( + groovy.text.SimpleTemplateEngine().createTemplate(indexTemplateHtml).make( + mapOf( + "OUTPUT" to jsFile, + //"TITLE" to korge.name, + "TITLE" to "TODO", + "CUSTOM_CSS" to customCss, + "CUSTOM_HTML_HEAD" to customHtmlHead, + "CUSTOM_HTML_BODY" to customHtmlBody + ) + ).toString() + ) + } + } + } + + project.configureEsbuild() + project.configureJavascriptRun() + project.configureDenoRun() + } + } + + //println("currentJavaVersion=${korlibs.currentJavaVersion()}") + /// + + fun Project.initShortcuts() { + rootProject.subprojectsThis { + afterEvaluate { + tasks { + val publishKotlinMultiplatformPublicationToMavenLocal = "publishKotlinMultiplatformPublicationToMavenLocal" + val publishKotlinMultiplatformPublicationToMavenRepository = "publishKotlinMultiplatformPublicationToMavenRepository" + + val publishJvmLocal = createThis("publishJvmLocal") { + if (findByName(publishKotlinMultiplatformPublicationToMavenLocal) != null) { + dependsOn("publishJvmPublicationToMavenLocal") + //dependsOn("publishMetadataPublicationToMavenLocal") + dependsOn(publishKotlinMultiplatformPublicationToMavenLocal) + } else if (findByName("publishToMavenLocal") != null) { + dependsOn("publishToMavenLocal") + } + } + + val publishJsLocal = createThis("publishJsLocal") { + if (findByName(publishKotlinMultiplatformPublicationToMavenLocal) != null) { + dependsOn("publishJsPublicationToMavenLocal") + //dependsOn("publishMetadataPublicationToMavenLocal") + dependsOn(publishKotlinMultiplatformPublicationToMavenLocal) + } + } + + val publishMacosX64Local = createThis("publishMacosX64Local") { + if (findByName(publishKotlinMultiplatformPublicationToMavenLocal) != null) { + dependsOn("publishMacosX64PublicationToMavenLocal") + dependsOn(publishKotlinMultiplatformPublicationToMavenLocal) + } + } + val publishMacosArm64Local = createThis("publishMacosArm64Local") { + if (findByName(publishKotlinMultiplatformPublicationToMavenLocal) != null) { + dependsOn("publishMacosArm64PublicationToMavenLocal") + dependsOn(publishKotlinMultiplatformPublicationToMavenLocal) + } + } + val publishIosX64Local = createThis("publishIosX64Local") { + if (findByName(publishKotlinMultiplatformPublicationToMavenLocal) != null) { + dependsOn("publishIosX64PublicationToMavenLocal") + dependsOn(publishKotlinMultiplatformPublicationToMavenLocal) + } + } + val publishIosArm64Local = createThis("publishIosArm64Local") { + if (findByName(publishKotlinMultiplatformPublicationToMavenLocal) != null) { + dependsOn("publishIosArm64PublicationToMavenLocal") + dependsOn(publishKotlinMultiplatformPublicationToMavenLocal) + } + } + val publishMobileLocal = createThis("publishMobileLocal") { + doFirst { + //if (currentJavaVersion != 8) error("To use publishMobileRepo, must be used Java8, but used Java$currentJavaVersion") + } + run { + val taskName = "publishJvmPublicationToMavenLocal" + if (findByName(taskName) != null) { + dependsOn(taskName) + } + } + if (findByName(publishKotlinMultiplatformPublicationToMavenLocal) != null) { + dependsOn(publishKotlinMultiplatformPublicationToMavenLocal) + dependsOn("publishAndroidPublicationToMavenLocal") + dependsOn("publishIosArm64PublicationToMavenLocal") + dependsOn("publishIosX64PublicationToMavenLocal") + } + } + + val customMavenUser = rootProject.findProperty("KORLIBS_CUSTOM_MAVEN_USER")?.toString() + val customMavenPass = rootProject.findProperty("KORLIBS_CUSTOM_MAVEN_PASS")?.toString() + val customMavenUrl = rootProject.findProperty("KORLIBS_CUSTOM_MAVEN_URL")?.toString() + val customPublishEnabled = forcedVersion != null + && !customMavenUser.isNullOrBlank() + && !customMavenPass.isNullOrBlank() + && !customMavenUrl.isNullOrBlank() + + val publishMobileRepo = createThis("publishMobileRepo") { + doFirst { + if (currentJavaVersion != 8) { + error("To use publishMobileRepo, must be used Java8, but used Java$currentJavaVersion") + } + if (!customPublishEnabled) { + error("To use publishMobileRepo, must set `FORCED_VERSION=...` environment variable, and in ~/.gradle/gradle.properties : KORLIBS_CUSTOM_MAVEN_USER, KORLIBS_CUSTOM_MAVEN_PASS & KORLIBS_CUSTOM_MAVEN_URL") + } + } + if (customPublishEnabled) { + run { + val taskName = "publishJvmPublicationToMavenRepository" + if (findByName(taskName) != null) { + dependsOn(taskName) + } + } + if (findByName(publishKotlinMultiplatformPublicationToMavenRepository) != null) { + dependsOn(publishKotlinMultiplatformPublicationToMavenRepository) + dependsOn("publishAndroidPublicationToMavenRepository") + dependsOn("publishIosArm64PublicationToMavenRepository") + dependsOn("publishIosX64PublicationToMavenRepository") + } + } + } + } + } + } + } + + fun Project.initTests() { + rootProject.subprojectsThis { + //tasks.withType(Test::class.java).allThis { + afterEvaluate { + it.configureTests() + project.configureDenoTest() + } + } + } + + fun Project.initCrossTests() { + rootProject.subprojectsThis { + afterEvaluate { + tasks { + afterEvaluate { + for (type in CrossExecType.VALID_LIST) { + val linkDebugTest = project.tasks.findByName("linkDebugTest${type.nameWithArchCapital}") as? KotlinNativeLink? + if (linkDebugTest != null) { + tasks.createThis("${type.nameWithArch}Test${type.interpCapital}") { + val link = linkDebugTest + val testResultsDir = project.buildDir.resolve(org.gradle.testing.base.plugins.TestingBasePlugin.TEST_RESULTS_DIR_NAME) + val testReportsDir = project.extensions.getByType(org.gradle.api.reporting.ReportingExtension::class.java).baseDir.resolve(org.gradle.testing.base.plugins.TestingBasePlugin.TESTS_DIR_NAME) + //this.configureConventions() + + val htmlReport = org.gradle.api.internal.plugins.DslObject(reports.html) + val xmlReport = org.gradle.api.internal.plugins.DslObject(reports.junitXml) + xmlReport.conventionMapping.map("destination") { testResultsDir.resolve(name) } + htmlReport.conventionMapping.map("destination") { testReportsDir.resolve(name) } + + this.type = type + this.executable = link.binary.outputFile + this.workingDir = link.binary.outputDirectory.absolutePath + this.binaryResultsDirectory.set(testResultsDir.resolve("$name/binary")) + this.environment("WINEDEBUG", "-all") + group = "verification" + dependsOn(link) + } + } + } + } + } + } + } + } +} + +//val headlessTests = true +//val headlessTests = System.getenv("NON_HEADLESS_TESTS") != "true" +val headlessTests: Boolean get() = System.getenv("CI") == "true" || System.getenv("HEADLESS_TESTS") == "true" +//val useMimalloc = false + +val Project._libs: Dyn get() = rootProject.extensions.getByName("libs").dyn +val Project.kotlinVersion: String get() = _libs["versions"]["kotlin"].dynamicInvoke("get").casted() +val Project.nodeVersion: String get() = _libs["versions"]["node"].dynamicInvoke("get").casted() +val Project.androidBuildGradleVersion: String get() = _libs["versions"]["android"]["build"]["gradle"].dynamicInvoke("get").casted() +val Project.realKotlinVersion: String get() = (System.getenv("FORCED_KOTLIN_VERSION") ?: kotlinVersion) +val forcedVersion = System.getenv("FORCED_VERSION") + +fun Project.getForcedVersion(): String { + return forcedVersion + ?.removePrefix("refs/tags/") + ?.removePrefix("v") + ?.removePrefix("w") + ?.removePrefix("z") + ?: project.version.toString() +} + +fun Project.getProjectForcedVersion(): String { + val res = when { + this.name.startsWith("korge-gradle-plugin") -> getForcedVersionGradlePluginVersion() + else -> getForcedVersionLibrariesVersion() + } + if (System.getenv("FORCED_VERSION") != null) { + println(":: PROJECT: name=${project.name}, version=$res") + } + return res +} + +fun Project.getForcedVersionGradlePluginVersion(): String { + return getForcedVersion().substringBefore("-only-gradle-plugin-") +} + +fun Project.getForcedVersionLibrariesVersion(): String { + return getForcedVersion().substringAfter("-only-gradle-plugin-") +} + +val Project.hasAndroidSdk by LazyExt { AndroidSdk.hasAndroidSdk(project) } +val Project.enabledSandboxResourceProcessor: Boolean get() = rootProject.findProperty("enabledSandboxResourceProcessor") == "true" + +val Project.currentJavaVersion by LazyExt { currentJavaVersion() } +fun Project.hasBuildGradle() = listOf("build.gradle", "build.gradle.kts").any { File(projectDir, it).exists() } +val Project.isSample: Boolean get() = project.path.startsWith(":samples:") || project.path.startsWith(":korge-sandbox") || project.path.startsWith(":korge-editor") || project.path.startsWith(":korge-starter-kit") +fun Project.mustAutoconfigureKMM(): Boolean = + !project.name.startsWith("korge-gradle-plugin") && + project.name != "korge-reload-agent" && + project.name != "korge-ipc" && + project.name != "korge-kotlin-compiler" && + project.name != "korge-benchmarks" && + project.hasBuildGradle() + +val Project.isKorgeBenchmarks: Boolean get() = path == ":korge-benchmarks" + +fun Project.nonSamples(block: Project.() -> Unit) { + subprojectsThis { + if (!project.isSample && project.hasBuildGradle()) { + block() + } + } +} + +fun Project.samples(block: Project.() -> Unit) { + subprojectsThis { + if (project.isSample && project.hasBuildGradle()) { + block() + } + } +} +fun Project.symlinktree(fromFolder: File, intoFolder: File) { + try { + if (!intoFolder.isDirectory && !Files.isSymbolicLink(intoFolder.toPath())) { + runCatching { intoFolder.delete() } + runCatching { intoFolder.deleteRecursively() } + intoFolder.parentFile.mkdirs() + val intoPath = intoFolder.toPath() + val relativeFromPath = intoFolder.parentFile.toPath().relativize(fromFolder.toPath()) + //if (isWindows) { + // exec { it.commandLine("cmd", "/c", "mklink", "/d", intoPath.pathString, relativeFromPath.pathString) } + //} else { + Files.createSymbolicLink(intoPath, relativeFromPath) + //} + } + } catch (e: Throwable) { + e.printStackTrace() + copy { + it.from(fromFolder) + it.into(intoFolder) + it.duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + } +} + +fun Project.runServer(blocking: Boolean, debug: Boolean = false) { + if (_webServer == null) { + val address = "0.0.0.0" + val port = 8080 + val server = staticHttpServer(File(project.buildDir, "www"), address = address, port = port) + _webServer = server + try { + val openAddress = when (address) { + "0.0.0.0" -> "127.0.0.1" + else -> address + } + val SUFFIX = if (debug) "?LOG_LEVEL=debug" else "" + openBrowser("http://$openAddress:${server.port}/index.html$SUFFIX") + if (blocking) { + while (true) { + Thread.sleep(1000L) + } + } + } finally { + if (blocking) { + println("Stopping web server...") + server.server.stop(0) + _webServer = null + } + } + } + _webServer?.updateVersion?.incrementAndGet() +} + +fun Project.execOutput(vararg args: String): String { + var out = "" + ByteArrayOutputStream().also { os -> + val result = execThis { + commandLine(*args) + standardOutput = os + } + result.assertNormalExitValue() + out = os.toString() + } + return out +} + + +internal var _webServer: DecoratedHttpServer? = null diff --git a/korge-gradle-plugin/src/main/kotlin/korlibs/tools.kt b/korge-gradle-plugin/src/main/kotlin/korlibs/tools.kt new file mode 100644 index 0000000000..2a33b356c5 --- /dev/null +++ b/korge-gradle-plugin/src/main/kotlin/korlibs/tools.kt @@ -0,0 +1,156 @@ +@file:Suppress("UNCHECKED_CAST") +package korlibs + +import groovy.util.* +import groovy.xml.XmlUtil +import org.apache.tools.ant.taskdefs.condition.* +import org.gradle.api.* +import org.gradle.api.artifacts.dsl.* +import org.gradle.api.plugins.* +import org.gradle.api.tasks.* +import org.jetbrains.kotlin.gradle.dsl.* +import org.jetbrains.kotlin.gradle.plugin.* +import org.jetbrains.kotlin.gradle.plugin.mpp.* +import java.io.* +import java.net.* +import java.util.zip.* + +fun MutableMap.applyProjectProperties( + projectUrl: String, + licenseName: String, + licenseUrl: String +) { + put("project.scm.url", projectUrl) + put("project.license.name", "MIT License") + put("project.license.url", "https://raw.githubusercontent.com/korlibs/korge/master/LICENSE") + put("project.author.id", "soywiz") + put("project.author.name", "Carlos Ballesteros Velasco") + put("project.author.email", "soywiz@gmail.com") +} + +fun MutableMap.includeKotlinNativeDesktop() { + this["include.kotlin.native.desktop"] = true +} + +// Extensions +operator fun File.get(name: String) = File(this, name) + +var File.bytes + get() = this.readBytes(); + set(value) { + this.also { it.parentFile.mkdirs() }.writeBytes(value) + } +var File.text + get() = this.readText(); + set(value) { + this.also { it.parentFile.mkdirs() }.writeText(value) + } + +fun File.ensureParents(): File = this.apply { this.parentFile.mkdirs() } + +// File and archives +fun Project.downloadFile(url: URL, localFile: File, connectionTimeout: Int = 15_000, readTimeout: Int = 15_000) { + logger.info("Downloading $url into $localFile ...") + url.openConnection().also { + it.connectTimeout = connectionTimeout + it.readTimeout = readTimeout + }.getInputStream().use { input -> + localFile.ensureParents().writeBytes(input.readAllBytes()) + //FileOutputStream(localFile.ensureParents()).use { output -> input.copyTo(output) } + } +} + +fun Project.extractArchive(archive: File, output: File) { + logger.info("Extracting $archive into $output ...") + copy { + when { + archive.name.endsWith(".tar.gz") -> it.from(tarTree(resources.gzip(archive))) + archive.name.endsWith(".zip") -> it.from(zipTree(archive)) + else -> error("Unsupported archive $archive") + } + it.into(output) + } +} + +val Project.selfExtra: ExtraPropertiesExtension get() = extensions.getByType(ExtraPropertiesExtension::class.java) +val Project.extra: ExtraPropertiesExtension get() = rootProject.extensions.getByType(ExtraPropertiesExtension::class.java) + +// Gradle extensions +operator fun Project.invoke(callback: Project.() -> Unit) = callback(this) +operator fun DependencyHandler.invoke(callback: DependencyHandler.() -> Unit) = callback(this) +operator fun KotlinMultiplatformExtension.invoke(callback: KotlinMultiplatformExtension.() -> Unit) = callback(this) +fun Project.tasks(callback: TaskContainer.() -> Unit) = this.tasks.apply(callback) + +inline fun Project.the(): T { + return extensions.getByType() +} +inline fun ExtensionContainer.getByType() = getByType(T::class.java) + +inline fun DomainObjectCollection.withType(): DomainObjectCollection = withType(T::class.java) +inline fun > PluginCollection.withType(): PluginCollection = withType(T::class.java) +inline fun DomainObjectCollection.allThis(noinline block: T.() -> Unit) = this.all(block) + +inline fun TaskCollection<*>.withType(noinline block: T.() -> Unit) { + return (this as TaskCollection).withType(T::class.java).configureEach(block) +} +inline fun ExtensionContainer.configure(noinline block: T.() -> Unit) { + return configure(T::class.java, Action { block(it) }) +} + +operator fun NamedDomainObjectCollection.get(name: String): T = this.getByName(name) +val NamedDomainObjectCollection.js: KotlinOnlyTarget get() = this["js"] as KotlinOnlyTarget +val NamedDomainObjectCollection.jvm: KotlinOnlyTarget get() = this["jvm"] as KotlinOnlyTarget +val NamedDomainObjectCollection.metadata: KotlinOnlyTarget get() = this["metadata"] as KotlinOnlyTarget +val > NamedDomainObjectContainer.main: T get() = this["main"] +val > NamedDomainObjectContainer.test: T get() = this["test"] + +inline fun TaskContainer.create(name: String, callback: T.() -> Unit) = create(name, T::class.java).apply(callback) + +val Project.gkotlin get() = extensions.getByType() +fun Project.gkotlin(callback: KotlinMultiplatformExtension.() -> Unit) = gkotlin.apply(callback) + +val Project.kotlin get() = extensions.getByType() +fun Project.kotlin(callback: KotlinMultiplatformExtension.() -> Unit) = gkotlin.apply(callback) + +fun Project.allprojectsThis(block: Project.() -> Unit) = allprojects(block) +fun Project.subprojectsThis(block: Project.() -> Unit) = subprojects(block) + +// Groovy tools +fun Node.toXmlString() = XmlUtil.serialize(this) + +fun Project.doOnce(uniqueName: String, block: () -> Unit) { + val key = "doOnce-$uniqueName" + if (!rootProject.extra.has(key)) { + rootProject.extra.set(key, true) + block() + } +} + +fun Project.doOncePerProject(uniqueName: String, block: () -> Unit) { + val key = "doOnceProject-${project.name}-$uniqueName" + if (!project.extra.has(key)) { + project.extra.set(key, true) + block() + } +} + +fun currentJavaVersion(): Int { + val versionElements = System.getProperty("java.version").split("\\.".toRegex()).toTypedArray() + arrayOf("-1", "-1") + val discard = versionElements[0].toInt() + return if (discard == 1) versionElements[1].toInt() else discard +} + +fun unzipTo(output: File, zipFileName: File) { + ZipFile(zipFileName).use { zip -> + for (entry in zip.entries().asSequence()) { + val outFile = File(output, entry.name) + if (entry.isDirectory) { + outFile.mkdirs() + } else { + val bytes = zip.getInputStream(entry).use { it.readBytes() } + outFile.parentFile.mkdirs() + outFile.writeBytes(bytes) + } + } + } +} diff --git a/korge-gradle-plugin/src/main/resources/META-INF/services/korlibs.korge.gradle.processor.KorgeResourceProcessor b/korge-gradle-plugin/src/main/resources/META-INF/services/korlibs.korge.gradle.processor.KorgeResourceProcessor new file mode 100644 index 0000000000..c1d5c80990 --- /dev/null +++ b/korge-gradle-plugin/src/main/resources/META-INF/services/korlibs.korge.gradle.processor.KorgeResourceProcessor @@ -0,0 +1 @@ +korlibs.korge.gradle.processor.KorgeTexturePacker diff --git a/korge-gradle-plugin/src/main/resources/banners/korge.png b/korge-gradle-plugin/src/main/resources/banners/korge.png new file mode 100644 index 0000000000..a60c8f7c91 Binary files /dev/null and b/korge-gradle-plugin/src/main/resources/banners/korge.png differ diff --git a/korge-gradle-plugin/src/main/resources/custom-html-body.template.html b/korge-gradle-plugin/src/main/resources/custom-html-body.template.html new file mode 100644 index 0000000000..c4a72a2ef9 --- /dev/null +++ b/korge-gradle-plugin/src/main/resources/custom-html-body.template.html @@ -0,0 +1 @@ + diff --git a/korge-gradle-plugin/src/main/resources/custom-html-head.template.html b/korge-gradle-plugin/src/main/resources/custom-html-head.template.html new file mode 100644 index 0000000000..54c9aa6cc8 --- /dev/null +++ b/korge-gradle-plugin/src/main/resources/custom-html-head.template.html @@ -0,0 +1 @@ + diff --git a/korge-gradle-plugin/src/main/resources/custom-styles.template.css b/korge-gradle-plugin/src/main/resources/custom-styles.template.css new file mode 100644 index 0000000000..7a2154fe2c --- /dev/null +++ b/korge-gradle-plugin/src/main/resources/custom-styles.template.css @@ -0,0 +1 @@ +/* custom-styles.template.css */ diff --git a/korge-gradle-plugin/src/main/resources/icons/korge.png b/korge-gradle-plugin/src/main/resources/icons/korge.png new file mode 100644 index 0000000000..1d5ccf6e89 Binary files /dev/null and b/korge-gradle-plugin/src/main/resources/icons/korge.png differ diff --git a/korge-gradle-plugin/src/main/resources/index.v2.template.html b/korge-gradle-plugin/src/main/resources/index.v2.template.html new file mode 100644 index 0000000000..8bc70f4fe1 --- /dev/null +++ b/korge-gradle-plugin/src/main/resources/index.v2.template.html @@ -0,0 +1,92 @@ + + + + + + + + + + $TITLE + + $CUSTOM_HTML_HEAD + + + + + +
+
+
+
+ Loading game... + +
+
+
+ + + +$CUSTOM_HTML_BODY + + + + + + + diff --git a/korge-gradle-plugin/src/main/resources/korge.keystore b/korge-gradle-plugin/src/main/resources/korge.keystore new file mode 100644 index 0000000000..f944e00c7a Binary files /dev/null and b/korge-gradle-plugin/src/main/resources/korge.keystore differ diff --git a/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/typedresources/AseSpriteTest.kt b/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/typedresources/AseSpriteTest.kt new file mode 100644 index 0000000000..3821b88eff --- /dev/null +++ b/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/typedresources/AseSpriteTest.kt @@ -0,0 +1,14 @@ +package korlibs.korge.gradle.typedresources + +import korlibs.korge.gradle.util.* +import org.junit.Assert +import org.junit.Test + +class AseSpriteTest { + @Test + fun test() { + val info = ASEInfo.Companion.getAseInfo(getResourceBytes("sprites.ase")) + Assert.assertEquals(0, info.slices.size) + Assert.assertEquals(listOf("TestNum", "FireTrail", "FireTrail2"), info.tags.map { it.tagName }) + } +} diff --git a/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/typedresources/ResourceTools.kt b/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/typedresources/ResourceTools.kt new file mode 100644 index 0000000000..aa0b86fcce --- /dev/null +++ b/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/typedresources/ResourceTools.kt @@ -0,0 +1,13 @@ +package korlibs.korge.gradle.typedresources + +import java.net.* + +class ResourceTools + +fun getResourceBytes(path: String): ByteArray = getResourceURL(path).readBytes() +fun getResourceText(path: String): String = getResourceURL(path).readText() + +fun getResourceURL(path: String): URL { + return ResourceTools::class.java.getResource("/${path.trim('/')}") + ?: error("Can't find '$path' in class loaders") +} diff --git a/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/typedresources/TypedResourcesGeneratorTest.kt b/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/typedresources/TypedResourcesGeneratorTest.kt new file mode 100644 index 0000000000..401f9ca386 --- /dev/null +++ b/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/typedresources/TypedResourcesGeneratorTest.kt @@ -0,0 +1,39 @@ +package korlibs.korge.gradle.typedresources + +import korlibs.korge.gradle.util.* +import org.junit.* +import org.junit.Assert.assertEquals + +class TypedResourcesGeneratorTest { + @Test + fun test() { + val generated = TypedResourcesGenerator().generateForFolders( + MemorySFile( + "hello.png" to "", + "sfx/sound.mp3" to "", + "gfx/demo.atlas/hello.png" to "", + "gfx/demo.atlas/world.png" to "", + "0000/1111/222a.png" to "", + "other/file.raw" to "", + "fonts/hello.ttf" to "", + "images/image.ase" to "", + "images/image2.ase" to "INVALID213123123621639172639127637216", + ) + ) { e, message -> + + } + + fun String.normalize(): String { + return this.trimIndent().replace("\t", " ").trim().lines().map { it.trimEnd() }.joinToString("\n") + } + + val generatedNormalized = generated.trim().normalize() + val expectedNormalized = getResourceText("expected.KR.generated.txt").normalize() + + if (expectedNormalized != generatedNormalized) { + println(generatedNormalized) + } + + assertEquals(expectedNormalized, generatedNormalized) + } +} diff --git a/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/util/UniqueNameGeneratorTest.kt b/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/util/UniqueNameGeneratorTest.kt new file mode 100644 index 0000000000..3da5d4b062 --- /dev/null +++ b/korge-gradle-plugin/src/test/kotlin/korlibs/korge/gradle/util/UniqueNameGeneratorTest.kt @@ -0,0 +1,19 @@ +package korlibs.korge.gradle.util + +import org.junit.* + +class UniqueNameGeneratorTest { + @Test + fun test() { + val names = UniqueNameGenerator() + Assert.assertEquals("hello", names.get("hello")) + Assert.assertEquals("hello0", names.get("hello")) + Assert.assertEquals("hello1", names.get("hello")) + Assert.assertEquals("hello00", names.get("hello0")) + Assert.assertEquals("hello01", names.get("hello0")) + Assert.assertEquals("hello10", names.get("hello1")) + Assert.assertEquals("hello11", names.get("hello1")) + Assert.assertEquals("hello110", names.get("hello11")) + Assert.assertEquals("hello2", names.get("hello")) + } +} diff --git a/korge-gradle-plugin/src/test/resources/expected.KR.generated.txt b/korge-gradle-plugin/src/test/resources/expected.KR.generated.txt new file mode 100644 index 0000000000..2ed2357b5f --- /dev/null +++ b/korge-gradle-plugin/src/test/resources/expected.KR.generated.txt @@ -0,0 +1,111 @@ +import korlibs.audio.sound.* +import korlibs.io.file.* +import korlibs.io.file.std.* +import korlibs.image.bitmap.* +import korlibs.image.atlas.* +import korlibs.image.font.* +import korlibs.image.format.* + +// AUTO-GENERATED FILE! DO NOT MODIFY! + +@Retention(AnnotationRetention.BINARY) annotation class ResourceVfsPath(val path: String) +inline class TypedVfsFile(val __file: VfsFile) +inline class TypedVfsFileTTF(val __file: VfsFile) { + suspend fun read(): korlibs.image.font.TtfFont = this.__file.readTtfFont() +} +inline class TypedVfsFileBitmap(val __file: VfsFile) { + suspend fun read(): korlibs.image.bitmap.Bitmap = this.__file.readBitmap() + suspend fun readSlice(atlas: MutableAtlasUnit? = null, name: String? = null): BmpSlice = this.__file.readBitmapSlice(name, atlas) +} +inline class TypedVfsFileSound(val __file: VfsFile) { + suspend fun read(): korlibs.audio.sound.Sound = this.__file.readSound() +} +interface TypedAtlas + +object KR : __KR.KR + +object __KR { + + interface KR { + val __file get() = resourcesVfs[""] + @ResourceVfsPath("0000") val `n0000` get() = __KR.KR0000 + @ResourceVfsPath("fonts") val `fonts` get() = __KR.KRFonts + @ResourceVfsPath("gfx") val `gfx` get() = __KR.KRGfx + @ResourceVfsPath("hello.png") val `hello` get() = TypedVfsFileBitmap(resourcesVfs["hello.png"]) + @ResourceVfsPath("images") val `images` get() = __KR.KRImages + @ResourceVfsPath("other") val `other` get() = __KR.KROther + @ResourceVfsPath("sfx") val `sfx` get() = __KR.KRSfx + } + + object KR0000 { + val __file get() = resourcesVfs["0000"] + @ResourceVfsPath("0000/1111") val `n1111` get() = __KR.KR00001111 + } + + object KRFonts { + val __file get() = resourcesVfs["fonts"] + @ResourceVfsPath("fonts/hello.ttf") val `hello` get() = TypedVfsFileTTF(resourcesVfs["fonts/hello.ttf"]) + } + + object KRGfx { + val __file get() = resourcesVfs["gfx"] + @ResourceVfsPath("gfx/demo.atlas.json") val `demo` get() = AtlasGfxDemoAtlas.TypedAtlas(resourcesVfs["gfx/demo.atlas.json"]) + } + + object KRImages { + val __file get() = resourcesVfs["images"] + @ResourceVfsPath("images/image.ase") val `image` get() = AseImagesImageAse.TypedAse(resourcesVfs["images/image.ase"]) + @ResourceVfsPath("images/image2.ase") val `image2` get() = AseImagesImage2Ase.TypedAse(resourcesVfs["images/image2.ase"]) + } + + object KROther { + val __file get() = resourcesVfs["other"] + @ResourceVfsPath("other/file.raw") val `file` get() = TypedVfsFile(resourcesVfs["other/file.raw"]) + } + + object KRSfx { + val __file get() = resourcesVfs["sfx"] + @ResourceVfsPath("sfx/sound.mp3") val `sound` get() = TypedVfsFileSound(resourcesVfs["sfx/sound.mp3"]) + } + + object KR00001111 { + val __file get() = resourcesVfs["0000/1111"] + @ResourceVfsPath("0000/1111/222a.png") val `n222a` get() = TypedVfsFileBitmap(resourcesVfs["0000/1111/222a.png"]) + } +} + +inline class AtlasGfxDemoAtlas(val __atlas: korlibs.image.atlas.Atlas) { + inline class TypedAtlas(val __file: VfsFile) { suspend fun read(): AtlasGfxDemoAtlas = AtlasGfxDemoAtlas(this.__file.readAtlas()) } + @ResourceVfsPath("gfx/demo.atlas/hello.png") val `hello` get() = __atlas["hello.png"] + @ResourceVfsPath("gfx/demo.atlas/world.png") val `world` get() = __atlas["world.png"] +} + +inline class AseImagesImageAse(val data: korlibs.image.format.ImageDataContainer) { + inline class TypedAse(val __file: VfsFile) { suspend fun read(atlas: korlibs.image.atlas.MutableAtlasUnit? = null): AseImagesImageAse = AseImagesImageAse(this.__file.readImageDataContainer(korlibs.image.format.ASE.toProps(), atlas)) } + enum class TypedAnimation(val animationName: String) { + ; + companion object { + val list: List = values().toList() + } + } + inline class TypedImageData(val data: ImageData) { + val animations: TypedAnimation.Companion get() = TypedAnimation + } + val animations: TypedAnimation.Companion get() = TypedAnimation + val default: TypedImageData get() = TypedImageData(data.default) +} + +inline class AseImagesImage2Ase(val data: korlibs.image.format.ImageDataContainer) { + inline class TypedAse(val __file: VfsFile) { suspend fun read(atlas: korlibs.image.atlas.MutableAtlasUnit? = null): AseImagesImage2Ase = AseImagesImage2Ase(this.__file.readImageDataContainer(korlibs.image.format.ASE.toProps(), atlas)) } + enum class TypedAnimation(val animationName: String) { + ; + companion object { + val list: List = values().toList() + } + } + inline class TypedImageData(val data: ImageData) { + val animations: TypedAnimation.Companion get() = TypedAnimation + } + val animations: TypedAnimation.Companion get() = TypedAnimation + val default: TypedImageData get() = TypedImageData(data.default) +} diff --git a/korge-gradle-plugin/src/test/resources/sprites.ase b/korge-gradle-plugin/src/test/resources/sprites.ase new file mode 100644 index 0000000000..70daa6fbfc Binary files /dev/null and b/korge-gradle-plugin/src/test/resources/sprites.ase differ