Unverified Commit 44f2d511 authored by Ilya Ryzhenkov's avatar Ilya Ryzhenkov Committed by GitHub

Support tvOS (#719)

parent 5058bbc1
...@@ -46,7 +46,7 @@ jobs: ...@@ -46,7 +46,7 @@ jobs:
path: ./skiko/build/reports/tests path: ./skiko/build/reports/tests
retention-days: 5 retention-days: 5
ios: iOS:
runs-on: macos-13 runs-on: macos-13
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
...@@ -99,6 +99,29 @@ jobs: ...@@ -99,6 +99,29 @@ jobs:
path: ./skiko/build/reports/tests path: ./skiko/build/reports/tests
retention-days: 5 retention-days: 5
tvOS:
# TVOS requires macos 13
runs-on: macos-13
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- uses: actions/setup-java@v2
with:
distribution: 'adopt'
java-version: '11'
- shell: bash
run: |
./gradlew --stacktrace --info -Pskiko.native.enabled=true -Pskiko.test.onci=true :skiko:tvosX64Test
# tvosSimulatorArm64Test will build the binary but the tests will be skipped due to X64 host machine
./gradlew --stacktrace --info -Pskiko.native.enabled=true -Pskiko.test.onci=true :skiko:tvosSimulatorArm64Test
- uses: actions/upload-artifact@v2
if: always()
with:
name: test-reports-macos
path: ./skiko/build/reports/tests
retention-days: 5
linux: linux:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
......
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.plugin.mpp.*
buildscript { buildscript {
repositories { repositories {
...@@ -82,11 +82,19 @@ kotlin { ...@@ -82,11 +82,19 @@ kotlin {
macosArm64() { macosArm64() {
configureToLaunchFromXcode() configureToLaunchFromXcode()
} }
ios() { iosSimulatorArm64() {
configureToLaunchFromAppCode() configureToLaunchFromAppCode()
configureToLaunchFromXcode() configureToLaunchFromXcode()
} }
iosSimulatorArm64() { tvosX64() {
configureToLaunchFromAppCode()
configureToLaunchFromXcode()
}
tvosArm64() {
configureToLaunchFromAppCode()
configureToLaunchFromXcode()
}
tvosSimulatorArm64() {
configureToLaunchFromAppCode() configureToLaunchFromAppCode()
configureToLaunchFromXcode() configureToLaunchFromXcode()
} }
...@@ -165,12 +173,27 @@ kotlin { ...@@ -165,12 +173,27 @@ kotlin {
val macosArm64Main by getting { val macosArm64Main by getting {
dependsOn(macosMain) dependsOn(macosMain)
} }
val iosMain by getting { val uikitMain by creating {
dependsOn(darwinMain) dependsOn(darwinMain)
} }
val iosMain by creating {
dependsOn(uikitMain)
}
val iosSimulatorArm64Main by getting { val iosSimulatorArm64Main by getting {
dependsOn(iosMain) dependsOn(iosMain)
} }
val tvosMain by creating {
dependsOn(uikitMain)
}
val tvosX64Main by getting {
dependsOn(tvosMain)
}
val tvosArm64Main by getting {
dependsOn(tvosMain)
}
val tvosSimulatorArm64Main by getting {
dependsOn(tvosMain)
}
} }
} }
} }
...@@ -237,8 +260,14 @@ tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinJsCompile>().configureEach ...@@ -237,8 +260,14 @@ tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinJsCompile>().configureEach
} }
enum class Target(val simulator: Boolean, val key: String) { enum class Target(val simulator: Boolean, val key: String) {
WATCHOS_X86(true, "watchos"), WATCHOS_ARM64(false, "watchos"), WATCHOS_X86(true, "watchos"),
IOS_X64(true, "iosX64"), IOS_ARM64(false, "iosArm64"), IOS_SIMULATOR_ARM64(true, "iosSimulatorArm64") WATCHOS_ARM64(false, "watchos"),
IOS_X64(true, "iosX64"),
IOS_ARM64(false, "iosArm64"),
IOS_SIMULATOR_ARM64(true, "iosSimulatorArm64"),
TVOS_X64(true, "tvosX64"),
TVOS_ARM64(true, "tvosArm64"),
TVOS_SIMULATOR_ARM64(true, "tvosSimulatorArm64"),
} }
...@@ -246,9 +275,16 @@ if (hostOs == "macos") { ...@@ -246,9 +275,16 @@ if (hostOs == "macos") {
// Create Xcode integration tasks. // Create Xcode integration tasks.
val sdkName: String? = System.getenv("SDK_NAME") val sdkName: String? = System.getenv("SDK_NAME")
println("Configuring XCode for $sdkName")
val target = sdkName.orEmpty().let { val target = sdkName.orEmpty().let {
when { when {
it.startsWith("iphoneos") -> Target.IOS_ARM64 it.startsWith("iphoneos") -> Target.IOS_ARM64
it.startsWith("appletvsimulator") -> when (host) {
"macos-x64" -> Target.TVOS_X64
"macos-arm64" -> Target.TVOS_SIMULATOR_ARM64
else -> throw GradleException("Host OS is not supported")
}
it.startsWith("appletvos") -> Target.TVOS_ARM64
it.startsWith("watchos") -> Target.WATCHOS_ARM64 it.startsWith("watchos") -> Target.WATCHOS_ARM64
it.startsWith("watchsimulator") -> Target.WATCHOS_X86 it.startsWith("watchsimulator") -> Target.WATCHOS_X86
else -> when (host) { else -> when (host) {
...@@ -280,6 +316,8 @@ if (hostOs == "macos") { ...@@ -280,6 +316,8 @@ if (hostOs == "macos") {
tasks.create("packForXCode", Copy::class.java) { tasks.create("packForXCode", Copy::class.java) {
dependsOn(kotlinBinary.linkTask) dependsOn(kotlinBinary.linkTask)
println("Packing for XCode: ${kotlinBinary.target}")
destinationDir = file(targetBuildDir) destinationDir = file(targetBuildDir)
val dsymSource = kotlinBinary.outputFile.absolutePath + ".dSYM" val dsymSource = kotlinBinary.outputFile.absolutePath + ".dSYM"
......
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>UILaunchStoryboardName</key>
<string></string>
</dict>
</plist>
name: SkikoSample name: Skiko Sample
options: options:
bundleIdPrefix: org.jetbrains bundleIdPrefix: org.jetbrains
settings: settings:
DEVELOPMENT_TEAM: N462MKSJ7M DEVELOPMENT_TEAM: N462MKSJ7M
CODE_SIGN_IDENTITY: "iPhone Developer" CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: Automatic CODE_SIGN_STYLE: Automatic
MARKETING_VERSION: "1.0" MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "4" CURRENT_PROJECT_VERSION: "4"
SDKROOT: iphoneos SDKROOT: iphoneos
targets: targets:
SkikoSample: Skiko Sample iOS:
type: application type: application
platform: iOS platform: iOS
deploymentTarget: "12.0" deploymentTarget: "12.0"
...@@ -32,3 +32,27 @@ targets: ...@@ -32,3 +32,27 @@ targets:
ENABLE_BITCODE: "YES" ENABLE_BITCODE: "YES"
ONLY_ACTIVE_ARCH: "NO" ONLY_ACTIVE_ARCH: "NO"
VALID_ARCHS: "arm64" VALID_ARCHS: "arm64"
Skiko Sample tvOS:
type: application
platform: tvOS
deploymentTarget: "14.0"
prebuildScripts:
- script: cd "$SRCROOT" && ./gradlew -p . packForXCode
name: GradleCompile
info:
path: plists/tvOS/Info.plist
properties:
UILaunchStoryboardName: ""
sources:
- path: "src/"
excludes:
- "android*/**"
- "awt*/**"
- "iosApp*/**"
- "js*/**"
settings:
LIBRARY_SEARCH_PATHS: "$(inherited)"
ENABLE_BITCODE: "YES"
ONLY_ACTIVE_ARCH: "NO"
VALID_ARCHS: "arm64"
...@@ -140,6 +140,15 @@ kotlin { ...@@ -140,6 +140,15 @@ kotlin {
if (supportNativeIosX64) { if (supportNativeIosX64) {
skikoProjectContext.configureNativeTarget(OS.IOS, Arch.X64, iosX64()) skikoProjectContext.configureNativeTarget(OS.IOS, Arch.X64, iosX64())
} }
if (supportNativeTvosArm64) {
skikoProjectContext.configureNativeTarget(OS.TVOS, Arch.Arm64, tvosArm64())
}
if (supportNativeTvosSimulatorArm64) {
skikoProjectContext.configureNativeTarget(OS.TVOS, Arch.Arm64, tvosSimulatorArm64())
}
if (supportNativeTvosX64) {
skikoProjectContext.configureNativeTarget(OS.TVOS, Arch.X64, tvosX64())
}
sourceSets { sourceSets {
val commonMain by getting { val commonMain by getting {
...@@ -299,12 +308,20 @@ kotlin { ...@@ -299,12 +308,20 @@ kotlin {
dependsOn(macosTest) dependsOn(macosTest)
} }
} }
if (supportAnyNativeIos || supportAllNativeTvos) {
val uikitMain by creating {
dependsOn(darwinMain)
}
val uikitTest by creating {
dependsOn(darwinTest)
}
if (supportAnyNativeIos) { if (supportAnyNativeIos) {
val iosMain by creating { val iosMain by creating {
dependsOn(darwinMain) dependsOn(uikitMain)
} }
val iosTest by creating { val iosTest by creating {
dependsOn(darwinTest) dependsOn(uikitTest)
} }
if (supportNativeIosArm64) { if (supportNativeIosArm64) {
val iosArm64Main by getting { val iosArm64Main by getting {
...@@ -331,6 +348,39 @@ kotlin { ...@@ -331,6 +348,39 @@ kotlin {
} }
} }
} }
if (supportAnyNativeTvos) {
val tvosMain by creating {
dependsOn(uikitMain)
}
val tvosTest by creating {
dependsOn(uikitTest)
}
if (supportNativeTvosArm64) {
val tvosArm64Main by getting {
dependsOn(tvosMain)
}
val tvosArm64Test by getting {
dependsOn(tvosTest)
}
}
if (supportNativeTvosSimulatorArm64) {
val tvosSimulatorArm64Main by getting {
dependsOn(tvosMain)
}
val tvosSimulatorArm64Test by getting {
dependsOn(tvosTest)
}
}
if (supportNativeTvosX64) {
val tvosX64Main by getting {
dependsOn(tvosMain)
}
val tvosX64Test by getting {
dependsOn(tvosTest)
}
}
}
}
} }
} }
} }
......
...@@ -30,10 +30,10 @@ class SkikoProjectContext( ...@@ -30,10 +30,10 @@ class SkikoProjectContext(
* (tasks' registration during other task's registration is prohibited) * (tasks' registration during other task's registration is prohibited)
*/ */
fun SkikoProjectContext.registerOrGetSkiaDirProvider( fun SkikoProjectContext.registerOrGetSkiaDirProvider(
os: OS, arch: Arch, isIosSim: Boolean = false os: OS, arch: Arch, isUikitSim: Boolean = false
): Provider<File> = with(this.project) { ): Provider<File> = with(this.project) {
val taskNameSuffix = joinToTitleCamelCase(buildType.id, os.idWithSuffix(isIosSim = isIosSim), arch.id) val taskNameSuffix = joinToTitleCamelCase(buildType.id, os.idWithSuffix(isUikitSim = isUikitSim), arch.id)
val skiaRelease = skiko.skiaReleaseFor(os, arch, buildType, isIosSim) val skiaRelease = skiko.skiaReleaseFor(os, arch, buildType, isUikitSim)
val downloadSkia = tasks.registerOrGetTask<Download>("downloadSkia$taskNameSuffix") { val downloadSkia = tasks.registerOrGetTask<Download>("downloadSkia$taskNameSuffix") {
onlyIf { !dest.exists() } onlyIf { !dest.exists() }
onlyIfModified(true) onlyIfModified(true)
...@@ -89,6 +89,21 @@ val Project.supportNativeIosX64: Boolean ...@@ -89,6 +89,21 @@ val Project.supportNativeIosX64: Boolean
val Project.supportAnyNativeIos: Boolean val Project.supportAnyNativeIos: Boolean
get() = supportAllNativeIos || supportNativeIosArm64 || supportNativeIosSimulatorArm64 || supportNativeIosX64 get() = supportAllNativeIos || supportNativeIosArm64 || supportNativeIosSimulatorArm64 || supportNativeIosX64
val Project.supportAllNativeTvos: Boolean
get() = supportAllNative || findProperty("skiko.native.tvos.enabled") == "true" || isInIdea
val Project.supportNativeTvosArm64: Boolean
get() = supportAllNativeTvos || findProperty("skiko.native.tvos.arm64.enabled") == "true" || isInIdea
val Project.supportNativeTvosSimulatorArm64: Boolean
get() = supportAllNativeTvos || findProperty("skiko.native.tvos.simulatorArm64.enabled") == "true" || isInIdea
val Project.supportNativeTvosX64: Boolean
get() = supportAllNativeTvos || findProperty("skiko.native.tvos.x64.enabled") == "true" || isInIdea
val Project.supportAnyNativeTvos: Boolean
get() = supportAllNativeTvos || supportNativeTvosArm64 || supportNativeTvosSimulatorArm64 || supportNativeTvosX64
val Project.supportNativeMac: Boolean val Project.supportNativeMac: Boolean
get() = supportAllNative || findProperty("skiko.native.mac.enabled") == "true" || isInIdea get() = supportAllNative || findProperty("skiko.native.mac.enabled") == "true" || isInIdea
......
...@@ -10,7 +10,8 @@ enum class OS( ...@@ -10,7 +10,8 @@ enum class OS(
Windows("windows", arrayOf()), Windows("windows", arrayOf()),
MacOS("macos", arrayOf("-mmacosx-version-min=10.13")), MacOS("macos", arrayOf("-mmacosx-version-min=10.13")),
Wasm("wasm", arrayOf()), Wasm("wasm", arrayOf()),
IOS("ios", arrayOf()) IOS("ios", arrayOf()),
TVOS("tvos", arrayOf())
; ;
val isWindows val isWindows
...@@ -19,8 +20,8 @@ enum class OS( ...@@ -19,8 +20,8 @@ enum class OS(
val isMacOs val isMacOs
get() = this == MacOS get() = this == MacOS
fun idWithSuffix(isIosSim: Boolean = false): String { fun idWithSuffix(isUikitSim: Boolean = false): String {
return id + if (isIosSim) "Sim" else "" return id + if (isUikitSim) "Sim" else ""
} }
} }
...@@ -28,7 +29,7 @@ val OS.isCompatibleWithHost: Boolean ...@@ -28,7 +29,7 @@ val OS.isCompatibleWithHost: Boolean
get() = when (this) { get() = when (this) {
OS.Linux -> hostOs == OS.Linux OS.Linux -> hostOs == OS.Linux
OS.Windows -> hostOs == OS.Windows OS.Windows -> hostOs == OS.Windows
OS.MacOS, OS.IOS -> hostOs == OS.MacOS OS.MacOS, OS.IOS, OS.TVOS -> hostOs == OS.MacOS
OS.Wasm -> true OS.Wasm -> true
OS.Android -> true OS.Android -> true
} }
...@@ -42,7 +43,7 @@ fun compilerForTarget(os: OS, arch: Arch): String = ...@@ -42,7 +43,7 @@ fun compilerForTarget(os: OS, arch: Arch): String =
} }
OS.Android -> "clang++" OS.Android -> "clang++"
OS.Windows -> "cl.exe" OS.Windows -> "cl.exe"
OS.MacOS, OS.IOS -> "clang++" OS.MacOS, OS.IOS, OS.TVOS -> "clang++"
OS.Wasm -> "emcc" OS.Wasm -> "emcc"
} }
...@@ -53,7 +54,7 @@ val OS.dynamicLibExt: String ...@@ -53,7 +54,7 @@ val OS.dynamicLibExt: String
get() = when (this) { get() = when (this) {
OS.Linux, OS.Android -> ".so" OS.Linux, OS.Android -> ".so"
OS.Windows -> ".dll" OS.Windows -> ".dll"
OS.MacOS, OS.IOS -> ".dylib" OS.MacOS, OS.IOS, OS.TVOS -> ".dylib"
OS.Wasm -> ".wasm" OS.Wasm -> ".wasm"
} }
...@@ -141,9 +142,9 @@ class SkikoProperties(private val myProject: Project) { ...@@ -141,9 +142,9 @@ class SkikoProperties(private val myProject: Project) {
get() = !isRelease get() = !isRelease
fun skiaReleaseFor(os: OS, arch: Arch, buildType: SkiaBuildType, isIosSim: Boolean = false): String { fun skiaReleaseFor(os: OS, arch: Arch, buildType: SkiaBuildType, isIosSim: Boolean = false): String {
val target = "${os.idWithSuffix(isIosSim = isIosSim)}-${arch.id}" val target = "${os.idWithSuffix(isUikitSim = isIosSim)}-${arch.id}"
val tag = myProject.property("dependencies.skia.$target") as String val tag = myProject.property("dependencies.skia.$target") as String
return "${tag}/Skia-${tag}-${os.idWithSuffix(isIosSim = isIosSim)}-${buildType.id}-${arch.id}" return "${tag}/Skia-${tag}-${os.idWithSuffix(isUikitSim = isIosSim)}-${buildType.id}-${arch.id}"
} }
val releaseGithubVersion: String val releaseGithubVersion: String
...@@ -208,6 +209,6 @@ object SkikoArtifacts { ...@@ -208,6 +209,6 @@ object SkikoArtifacts {
// does not seem possible (at least without adding a dash to a target's tasks), // does not seem possible (at least without adding a dash to a target's tasks),
// so we're using the default naming pattern instead. // so we're using the default naming pattern instead.
// See https://youtrack.jetbrains.com/issue/KT-50001. // See https://youtrack.jetbrains.com/issue/KT-50001.
fun nativeArtifactIdFor(os: OS, arch: Arch, isIosSim: Boolean = false) = fun nativeArtifactIdFor(os: OS, arch: Arch, isUikitSim: Boolean = false) =
"skiko-${os.id + if (isIosSim) "simulator" else ""}${arch.id}" "skiko-${os.id + if (isUikitSim) "simulator" else ""}${arch.id}"
} }
...@@ -75,6 +75,12 @@ fun skiaPreprocessorFlags(os: OS, buildType: SkiaBuildType): Array<String> { ...@@ -75,6 +75,12 @@ fun skiaPreprocessorFlags(os: OS, buildType: SkiaBuildType): Array<String> {
"-DSK_SHAPER_CORETEXT_AVAILABLE", "-DSK_SHAPER_CORETEXT_AVAILABLE",
"-DSK_METAL" "-DSK_METAL"
) )
OS.TVOS -> listOf(
"-DSK_BUILD_FOR_IOS",
"-DSK_BUILD_FOR_TVOS",
"-DSK_SHAPER_CORETEXT_AVAILABLE",
"-DSK_METAL"
)
OS.Windows -> listOf( OS.Windows -> listOf(
"-DSK_BUILD_FOR_WIN", "-DSK_BUILD_FOR_WIN",
"-D_CRT_SECURE_NO_WARNINGS", "-D_CRT_SECURE_NO_WARNINGS",
...@@ -155,10 +161,10 @@ fun KotlinTarget.generateVersion( ...@@ -155,10 +161,10 @@ fun KotlinTarget.generateVersion(
skikoProperties: SkikoProperties skikoProperties: SkikoProperties
) { ) {
val targetName = this.name val targetName = this.name
val isArm64Simulator = isIosSimArm64() val isUikitSim = isUikitSimulator()
val generatedDir = project.layout.buildDirectory.dir("generated/$targetName") val generatedDir = project.layout.buildDirectory.dir("generated/$targetName")
val generateVersionTask = project.registerSkikoTask<DefaultTask>( val generateVersionTask = project.registerSkikoTask<DefaultTask>(
"generateVersion${toTitleCase(platformType.name)}".withSuffix(isIosSim = isArm64Simulator), "generateVersion${toTitleCase(platformType.name)}".withSuffix(isUikitSim = isUikitSim),
targetOs, targetOs,
targetArch targetArch
) { ) {
......
...@@ -119,7 +119,7 @@ fun SkikoProjectContext.createCompileJvmBindingsTask( ...@@ -119,7 +119,7 @@ fun SkikoProjectContext.createCompileJvmBindingsTask(
"-fPIC" "-fPIC"
) )
} }
OS.Wasm, OS.IOS -> error("Should not reach here") OS.Wasm, OS.IOS, OS.TVOS -> error("Should not reach here")
} }
flags.set( flags.set(
...@@ -322,7 +322,7 @@ fun SkikoProjectContext.createLinkJvmBindings( ...@@ -322,7 +322,7 @@ fun SkikoProjectContext.createLinkJvmBindings(
) )
linker.set(project.androidClangFor(targetArch)) linker.set(project.androidClangFor(targetArch))
} }
OS.Wasm, OS.IOS -> { OS.Wasm, OS.IOS, OS.TVOS -> {
throw GradleException("This task shalln't be used with $targetOs") throw GradleException("This task shalln't be used with $targetOs")
} }
} }
......
...@@ -5,43 +5,38 @@ import CompileSkikoCppTask ...@@ -5,43 +5,38 @@ import CompileSkikoCppTask
import OS import OS
import SkiaBuildType import SkiaBuildType
import SkikoProjectContext import SkikoProjectContext
import SkikoProperties
import WriteCInteropDefFile import WriteCInteropDefFile
import compilerForTarget import compilerForTarget
import isCompatibleWithHost import isCompatibleWithHost
import joinToTitleCamelCase import joinToTitleCamelCase
import listOfFrameworks import listOfFrameworks
import mutableListOfLinkerOptions import mutableListOfLinkerOptions
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException import org.gradle.api.GradleException
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.tasks.Exec import org.gradle.api.tasks.Exec
import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.TaskProvider
import org.gradle.kotlin.dsl.get
import org.gradle.kotlin.dsl.getByName import org.gradle.kotlin.dsl.getByName
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget import org.jetbrains.kotlin.gradle.plugin.KotlinTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.tasks.CInteropProcess import org.jetbrains.kotlin.gradle.tasks.CInteropProcess
import org.jetbrains.kotlin.gradle.tasks.KotlinCompileTool
import projectDirs import projectDirs
import registerOrGetSkiaDirProvider import registerOrGetSkiaDirProvider
import registerSkikoTask import registerSkikoTask
import toTitleCase
import java.io.File import java.io.File
fun String.withSuffix(isIosSim: Boolean = false) = fun String.withSuffix(isUikitSim: Boolean = false) =
this + if (isIosSim) "Sim" else "" this + if (isUikitSim) "Sim" else ""
fun KotlinTarget.isIosSimArm64() = fun KotlinTarget.isUikitSimulator() =
name.contains("iosSimulatorArm64", ignoreCase = true) name.contains("Simulator", ignoreCase = true) || name == "tvosX64" // x64 tvOS is implicitly a simulator
fun SkikoProjectContext.compileNativeBridgesTask( fun SkikoProjectContext.compileNativeBridgesTask(
os: OS, arch: Arch, isArm64Simulator: Boolean os: OS, arch: Arch, isUikitSim: Boolean
): TaskProvider<CompileSkikoCppTask> = with (this.project) { ): TaskProvider<CompileSkikoCppTask> = with (this.project) {
val skiaNativeDir = registerOrGetSkiaDirProvider(os, arch, isIosSim = isArm64Simulator) val skiaNativeDir = registerOrGetSkiaDirProvider(os, arch, isUikitSim = isUikitSim)
val actionName = "compileNativeBridges".withSuffix(isIosSim = isArm64Simulator) val actionName = "compileNativeBridges".withSuffix(isUikitSim = isUikitSim)
return project.registerSkikoTask<CompileSkikoCppTask>(actionName, os, arch) { return project.registerSkikoTask<CompileSkikoCppTask>(actionName, os, arch) {
dependsOn(skiaNativeDir) dependsOn(skiaNativeDir)
...@@ -49,7 +44,7 @@ fun SkikoProjectContext.compileNativeBridgesTask( ...@@ -49,7 +44,7 @@ fun SkikoProjectContext.compileNativeBridgesTask(
compiler.set(compilerForTarget(os, arch)) compiler.set(compilerForTarget(os, arch))
buildTargetOS.set(os) buildTargetOS.set(os)
if (isArm64Simulator) { if (isUikitSim) {
buildSuffix.set("sim") buildSuffix.set("sim")
} }
buildTargetArch.set(arch) buildTargetArch.set(arch)
...@@ -62,8 +57,8 @@ fun SkikoProjectContext.compileNativeBridgesTask( ...@@ -62,8 +57,8 @@ fun SkikoProjectContext.compileNativeBridgesTask(
val iphoneSimSdk = "$sdkRoot/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk" val iphoneSimSdk = "$sdkRoot/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk"
val iosArchFlags = when (arch) { val iosArchFlags = when (arch) {
Arch.Arm64 -> arrayOf( Arch.Arm64 -> arrayOf(
"-target", if (isArm64Simulator) "arm64-apple-ios-simulator" else "arm64-apple-ios", "-target", if (isUikitSim) "arm64-apple-ios-simulator" else "arm64-apple-ios",
"-isysroot", if (isArm64Simulator) iphoneSimSdk else iphoneOsSdk, "-isysroot", if (isUikitSim) iphoneSimSdk else iphoneOsSdk,
"-miphoneos-version-min=12.0" "-miphoneos-version-min=12.0"
) )
Arch.X64 -> arrayOf( Arch.X64 -> arrayOf(
...@@ -80,6 +75,30 @@ fun SkikoProjectContext.compileNativeBridgesTask( ...@@ -80,6 +75,30 @@ fun SkikoProjectContext.compileNativeBridgesTask(
*skiaPreprocessorFlags(OS.IOS, buildType), *skiaPreprocessorFlags(OS.IOS, buildType),
)) ))
} }
OS.TVOS -> {
val sdkRoot = "/Applications/Xcode.app/Contents/Developer/Platforms"
val tvOsSdk = "$sdkRoot/AppleTVOS.platform/Developer/SDKs/AppleTVOS.sdk"
val tvSimSdk = "$sdkRoot/AppleTVSimulator.platform/Developer/SDKs/AppleTVSimulator.sdk"
val tvosArchFlags = when (arch) {
Arch.Arm64 -> arrayOf(
"-target", if (isUikitSim) "arm64-apple-tvos-simulator" else "arm64-apple-tvos",
if (isUikitSim) "-mappletvsimulator-version-min=12.0" else "-mappletvos-version-min=12.0" ,
"-isysroot", if (isUikitSim) tvSimSdk else tvOsSdk,
)
Arch.X64 -> arrayOf(
"-target", "x86_64-apple-tvos-simulator",
"-mappletvsimulator-version-min=12.0",
"-isysroot", tvSimSdk
)
else -> throw GradleException("Unsupported arch: $arch")
}
flags.set(listOf(
*tvosArchFlags,
*buildType.clangFlags,
"-stdlib=libc++",
*skiaPreprocessorFlags(OS.TVOS, buildType),
))
}
OS.MacOS -> { OS.MacOS -> {
flags.set(listOf( flags.set(listOf(
*buildType.clangFlags, *buildType.clangFlags,
...@@ -120,7 +139,7 @@ fun configureCinterop( ...@@ -120,7 +139,7 @@ fun configureCinterop(
linkerOpts: List<String>, linkerOpts: List<String>,
) { ) {
val tasks = target.project.tasks val tasks = target.project.tasks
val taskNameSuffix = joinToTitleCamelCase(os.idWithSuffix(isIosSim = target.isIosSimArm64()), arch.id) val taskNameSuffix = joinToTitleCamelCase(os.idWithSuffix(isUikitSim = target.isUikitSimulator()), arch.id)
val writeCInteropDef = tasks.register("writeCInteropDef$taskNameSuffix", WriteCInteropDefFile::class.java) { val writeCInteropDef = tasks.register("writeCInteropDef$taskNameSuffix", WriteCInteropDefFile::class.java) {
this.linkerOpts.set(linkerOpts) this.linkerOpts.set(linkerOpts)
outputFile.set(project.layout.buildDirectory.file("cinterop/$targetString/skiko.def")) outputFile.set(project.layout.buildDirectory.file("cinterop/$targetString/skiko.def"))
...@@ -167,11 +186,11 @@ fun SkikoProjectContext.configureNativeTarget(os: OS, arch: Arch, target: Kotlin ...@@ -167,11 +186,11 @@ fun SkikoProjectContext.configureNativeTarget(os: OS, arch: Arch, target: Kotlin
if (!os.isCompatibleWithHost) return if (!os.isCompatibleWithHost) return
target.generateVersion(os, arch, skiko) target.generateVersion(os, arch, skiko)
val isArm64Simulator = target.isIosSimArm64() val isUikitSim = target.isUikitSimulator()
val targetString = "${os.idWithSuffix(isIosSim = isArm64Simulator)}-${arch.id}" val targetString = "${os.idWithSuffix(isUikitSim = isUikitSim)}-${arch.id}"
val unzipper = registerOrGetSkiaDirProvider(os, arch, isArm64Simulator) val unzipper = registerOrGetSkiaDirProvider(os, arch, isUikitSim)
val unpackedSkia = unzipper.get() val unpackedSkia = unzipper.get()
val skiaDir = unpackedSkia.absolutePath val skiaDir = unpackedSkia.absolutePath
...@@ -194,6 +213,11 @@ fun SkikoProjectContext.configureNativeTarget(os: OS, arch: Arch, target: Kotlin ...@@ -194,6 +213,11 @@ fun SkikoProjectContext.configureNativeTarget(os: OS, arch: Arch, target: Kotlin
configureCinterop("uikit", os, arch, target, targetString, iosFrameworks) configureCinterop("uikit", os, arch, target, targetString, iosFrameworks)
mutableListOfLinkerOptions(iosFrameworks) mutableListOfLinkerOptions(iosFrameworks)
} }
OS.TVOS -> {
val tvosFrameworks = listOfFrameworks("Metal", "CoreGraphics", "CoreText", "UIKit")
configureCinterop("uikit", os, arch, target, targetString, tvosFrameworks)
mutableListOfLinkerOptions(tvosFrameworks)
}
OS.Linux -> mutableListOfLinkerOptions( OS.Linux -> mutableListOfLinkerOptions(
"-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib/x86_64-linux-gnu",
"-lfontconfig", "-lfontconfig",
...@@ -226,10 +250,10 @@ fun SkikoProjectContext.configureNativeTarget(os: OS, arch: Arch, target: Kotlin ...@@ -226,10 +250,10 @@ fun SkikoProjectContext.configureNativeTarget(os: OS, arch: Arch, target: Kotlin
} }
} }
val crossCompileTask = compileNativeBridgesTask(os, arch, isArm64Simulator = isArm64Simulator) val crossCompileTask = compileNativeBridgesTask(os, arch, isUikitSim = isUikitSim)
// TODO: move to LinkSkikoTask. // TODO: move to LinkSkikoTask.
val actionName = "linkNativeBridges".withSuffix(isIosSim = isArm64Simulator) val actionName = "linkNativeBridges".withSuffix(isUikitSim = isUikitSim)
val linkTask = project.registerSkikoTask<Exec>(actionName, os, arch) { val linkTask = project.registerSkikoTask<Exec>(actionName, os, arch) {
dependsOn(crossCompileTask) dependsOn(crossCompileTask)
val objectFilesDir = crossCompileTask.map { it.outDir.get() } val objectFilesDir = crossCompileTask.map { it.outDir.get() }
...@@ -245,7 +269,7 @@ fun SkikoProjectContext.configureNativeTarget(os: OS, arch: Arch, target: Kotlin ...@@ -245,7 +269,7 @@ fun SkikoProjectContext.configureNativeTarget(os: OS, arch: Arch, target: Kotlin
executable = "ar" executable = "ar"
argumentProviders.add { listOf("-crs", staticLib) } argumentProviders.add { listOf("-crs", staticLib) }
} }
OS.MacOS, OS.IOS -> { OS.MacOS, OS.IOS, OS.TVOS -> {
executable = "libtool" executable = "libtool"
argumentProviders.add { listOf("-static", "-o", staticLib) } argumentProviders.add { listOf("-static", "-o", staticLib) }
} }
......
...@@ -23,7 +23,10 @@ val skikoArtifactIds: List<String> = ...@@ -23,7 +23,10 @@ val skikoArtifactIds: List<String> =
SkikoArtifacts.nativeArtifactIdFor(OS.MacOS, Arch.X64), SkikoArtifacts.nativeArtifactIdFor(OS.MacOS, Arch.X64),
SkikoArtifacts.nativeArtifactIdFor(OS.IOS, Arch.X64), SkikoArtifacts.nativeArtifactIdFor(OS.IOS, Arch.X64),
SkikoArtifacts.nativeArtifactIdFor(OS.IOS, Arch.Arm64), SkikoArtifacts.nativeArtifactIdFor(OS.IOS, Arch.Arm64),
SkikoArtifacts.nativeArtifactIdFor(OS.IOS, Arch.Arm64, isIosSim = true), SkikoArtifacts.nativeArtifactIdFor(OS.IOS, Arch.Arm64, isUikitSim = true),
SkikoArtifacts.nativeArtifactIdFor(OS.TVOS, Arch.X64),
SkikoArtifacts.nativeArtifactIdFor(OS.TVOS, Arch.Arm64),
SkikoArtifacts.nativeArtifactIdFor(OS.TVOS, Arch.Arm64, isUikitSim = true),
) )
val downloadSkikoArtifactsFromComposeDev by tasks.registering(DownloadFromSpaceMavenRepoTask::class) { val downloadSkikoArtifactsFromComposeDev by tasks.registering(DownloadFromSpaceMavenRepoTask::class) {
......
...@@ -14,6 +14,10 @@ dependencies.skia.iosSim-arm64=m116-47d3027-1 ...@@ -14,6 +14,10 @@ dependencies.skia.iosSim-arm64=m116-47d3027-1
dependencies.skia.iosSim-x64=m116-47d3027-1 dependencies.skia.iosSim-x64=m116-47d3027-1
dependencies.skia.android-x64=m116-47d3027-1 dependencies.skia.android-x64=m116-47d3027-1
dependencies.skia.android-arm64=m116-47d3027-1 dependencies.skia.android-arm64=m116-47d3027-1
dependencies.skia.tvos-x64=m116-47d3027-1
dependencies.skia.tvos-arm64=m116-47d3027-1
dependencies.skia.tvosSim-arm64=m116-47d3027-1
dependencies.skia.tvosSim-x64=m116-47d3027-1
org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=512m org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=512m
......
...@@ -38,7 +38,7 @@ internal actual fun makeDefaultRenderFactory(): RenderFactory = ...@@ -38,7 +38,7 @@ internal actual fun makeDefaultRenderFactory(): RenderFactory =
GraphicsApi.SOFTWARE_FAST -> LinuxSoftwareRedrawer(layer, analytics, properties) GraphicsApi.SOFTWARE_FAST -> LinuxSoftwareRedrawer(layer, analytics, properties)
else -> LinuxOpenGLRedrawer(layer, analytics, properties) else -> LinuxOpenGLRedrawer(layer, analytics, properties)
} }
OS.Android, OS.JS, OS.Ios, OS.Unknown -> throw UnsupportedOperationException("The AWT target doesn't support $hostOs") OS.Android, OS.JS, OS.Ios, OS.Tvos, OS.Unknown -> throw UnsupportedOperationException("The awt target doesn't support $hostOs")
} }
} }
...@@ -51,7 +51,7 @@ internal actual fun URIHandler_openUri(uri: String) { ...@@ -51,7 +51,7 @@ internal actual fun URIHandler_openUri(uri: String) {
URI(uri) // Validate URI for exception behavior consistent with the Desktop.browse() case (throwing URISyntaxException) URI(uri) // Validate URI for exception behavior consistent with the Desktop.browse() case (throwing URISyntaxException)
Runtime.getRuntime().exec(arrayOf("xdg-open", URL(uri).toString())) Runtime.getRuntime().exec(arrayOf("xdg-open", URL(uri).toString()))
} }
OS.Android, OS.Windows, OS.MacOS, OS.Ios, OS.JS, OS.Unknown -> { OS.Android, OS.Windows, OS.MacOS, OS.Ios, OS.Tvos, OS.JS, OS.Unknown -> {
throw UnsupportedOperationException("AWT does not support the BROWSE action on this platform") throw UnsupportedOperationException("AWT does not support the BROWSE action on this platform")
} }
} }
......
...@@ -125,7 +125,7 @@ internal val platformOperations: PlatformOperations by lazy { ...@@ -125,7 +125,7 @@ internal val platformOperations: PlatformOperations by lazy {
} }
} }
OS.Android -> TODO() OS.Android -> TODO()
OS.JS, OS.Ios, OS.Unknown -> { OS.JS, OS.Ios, OS.Tvos, OS.Unknown -> {
TODO("Commonize me") TODO("Commonize me")
} }
} }
......
...@@ -6,6 +6,7 @@ enum class OS(val id: String) { ...@@ -6,6 +6,7 @@ enum class OS(val id: String) {
Windows("windows"), Windows("windows"),
MacOS("macos"), MacOS("macos"),
Ios("ios"), Ios("ios"),
Tvos("tvos"),
@Deprecated("JS is invalid host OS name. Consider using enum KotlinBackend to detect JS.") @Deprecated("JS is invalid host OS name. Consider using enum KotlinBackend to detect JS.")
JS("js"), JS("js"),
......
...@@ -17,7 +17,7 @@ class BreakIteratorTests { ...@@ -17,7 +17,7 @@ class BreakIteratorTests {
fun breakIteratorWordInstanceTest() { fun breakIteratorWordInstanceTest() {
// Wasm and iOS builds of Skia do not include required data to implement those iterators, // Wasm and iOS builds of Skia do not include required data to implement those iterators,
// see `third_party/externals/icu/flutter/README.md`. // see `third_party/externals/icu/flutter/README.md`.
if (hostOs == OS.Ios) if (hostOs == OS.Ios || hostOs == OS.Tvos)
return return
val boundary = BreakIterator.makeWordInstance() val boundary = BreakIterator.makeWordInstance()
......
package org.jetbrains.skiko package org.jetbrains.skiko
import platform.Foundation.NSURL.Companion.URLWithString import platform.UIKit.*
import platform.UIKit.UIApplication
import platform.UIKit.UIPasteboard
internal actual fun URIHandler_openUri(uri: String) {
UIApplication.sharedApplication.openURL(URLWithString(uri)!!)
}
internal actual fun ClipboardManager_setText(text: String) { internal actual fun ClipboardManager_setText(text: String) {
UIPasteboard.generalPasteboard.string = text UIPasteboard.generalPasteboard.string = text
...@@ -17,17 +11,7 @@ internal actual fun ClipboardManager_getText(): String? { ...@@ -17,17 +11,7 @@ internal actual fun ClipboardManager_getText(): String? {
internal actual fun ClipboardManager_hasText(): Boolean = UIPasteboard.generalPasteboard.hasStrings() internal actual fun ClipboardManager_hasText(): Boolean = UIPasteboard.generalPasteboard.hasStrings()
// TODO: not sure if correct. internal actual fun UIView.skikoInitializeUIView() {
actual typealias Cursor = Any multipleTouchEnabled = true
userInteractionEnabled = true
internal actual fun CursorManager_setCursor(component: Any, cursor: Cursor) {} }
internal actual fun CursorManager_getCursor(component: Any): Cursor? = null
internal actual fun getCursorById(id: PredefinedCursorsId): Cursor =
when (id) {
PredefinedCursorsId.DEFAULT -> Any()
PredefinedCursorsId.CROSSHAIR -> Any()
PredefinedCursorsId.HAND -> Any()
PredefinedCursorsId.TEXT -> Any()
}
\ No newline at end of file
package org.jetbrains.skiko
interface TextActions {
/**
* Copy action. If null, then copy is not possible in current context
*/
val copy: (() -> Unit)?
/**
* Paste action. If null, then paste is not possible in current context
*/
val paste: (() -> Unit)?
/**
* Cut action. If null, then cut is not possible in current context
*/
val cut: (() -> Unit)?
/**
* SelectAll action. If null, then select all is not possible in current context
*/
val selectAll: (() -> Unit)?
}
...@@ -96,7 +96,7 @@ object SkikoProperties { ...@@ -96,7 +96,7 @@ object SkikoProperties {
OS.Linux -> return GraphicsApi.OPENGL OS.Linux -> return GraphicsApi.OPENGL
OS.Windows -> return GraphicsApi.DIRECT3D OS.Windows -> return GraphicsApi.DIRECT3D
OS.Android -> return GraphicsApi.OPENGL OS.Android -> return GraphicsApi.OPENGL
OS.JS, OS.Ios, OS.Unknown -> TODO("commonize me") OS.JS, OS.Ios, OS.Tvos, OS.Unknown -> TODO("commonize me")
} }
} }
...@@ -110,7 +110,7 @@ object SkikoProperties { ...@@ -110,7 +110,7 @@ object SkikoProperties {
else -> listOf(GraphicsApi.DIRECT3D, GraphicsApi.OPENGL, GraphicsApi.SOFTWARE_FAST, GraphicsApi.SOFTWARE_COMPAT) else -> listOf(GraphicsApi.DIRECT3D, GraphicsApi.OPENGL, GraphicsApi.SOFTWARE_FAST, GraphicsApi.SOFTWARE_COMPAT)
} }
OS.Android -> return listOf(GraphicsApi.OPENGL) OS.Android -> return listOf(GraphicsApi.OPENGL)
OS.JS, OS.Ios, OS.Unknown -> TODO("commonize me") OS.JS, OS.Ios, OS.Tvos, OS.Unknown -> TODO("commonize me")
} }
val indexOfInitialApi = fallbackApis.indexOf(initialApi) val indexOfInitialApi = fallbackApis.indexOf(initialApi)
......
...@@ -9,6 +9,7 @@ actual val hostOs: OS by lazy { ...@@ -9,6 +9,7 @@ actual val hostOs: OS by lazy {
OsFamily.LINUX -> OS.Linux OsFamily.LINUX -> OS.Linux
OsFamily.WINDOWS -> OS.Windows OsFamily.WINDOWS -> OS.Windows
OsFamily.IOS -> OS.Ios OsFamily.IOS -> OS.Ios
OsFamily.TVOS -> OS.Tvos
else -> throw Error("Unsupported OS ${Platform.osFamily}") else -> throw Error("Unsupported OS ${Platform.osFamily}")
} }
} }
......
package org.jetbrains.skiko
import platform.UIKit.*
// tvOS doesn't have support for clipboard
internal actual fun ClipboardManager_setText(text: String) {
}
internal actual fun ClipboardManager_getText(): String? {
return null
}
internal actual fun ClipboardManager_hasText(): Boolean = false
internal actual fun UIView.skikoInitializeUIView() {
userInteractionEnabled = true
}
package org.jetbrains.skiko
import platform.Foundation.NSURL.Companion.URLWithString
import platform.UIKit.UIApplication
internal actual fun URIHandler_openUri(uri: String) {
UIApplication.sharedApplication.openURL(URLWithString(uri)!!)
}
// TODO: not sure if correct.
actual typealias Cursor = Any
internal actual fun CursorManager_setCursor(component: Any, cursor: Cursor) {}
internal actual fun CursorManager_getCursor(component: Any): Cursor? = null
internal actual fun getCursorById(id: PredefinedCursorsId): Cursor =
when (id) {
PredefinedCursorsId.DEFAULT -> Any()
PredefinedCursorsId.CROSSHAIR -> Any()
PredefinedCursorsId.HAND -> Any()
PredefinedCursorsId.TEXT -> Any()
}
\ No newline at end of file
package org.jetbrains.skiko package org.jetbrains.skiko
import kotlinx.cinterop.* import kotlinx.cinterop.*
import org.jetbrains.skia.Point import org.jetbrains.skiko.redrawer.*
import org.jetbrains.skia.Rect
import org.jetbrains.skiko.ios.SkikoUITextInputTraits
import org.jetbrains.skiko.redrawer.MetalRedrawer
import platform.CoreGraphics.* import platform.CoreGraphics.*
import platform.Foundation.* import platform.Foundation.*
import platform.Metal.MTLCreateSystemDefaultDevice import platform.Metal.*
import platform.Metal.MTLDeviceProtocol import platform.QuartzCore.*
import platform.Metal.MTLPixelFormatBGRA8Unorm
import platform.QuartzCore.CAMetalLayer
import platform.UIKit.* import platform.UIKit.*
import platform.darwin.NSInteger import kotlin.native.ref.*
import kotlin.math.max
import kotlin.math.min internal expect fun UIView.skikoInitializeUIView(): Unit
import kotlin.native.ref.WeakReference
@Suppress("CONFLICTING_OVERLOADS") @Suppress("CONFLICTING_OVERLOADS")
@ExportObjCClass @ExportObjCClass
...@@ -37,7 +31,7 @@ class SkikoUIView : UIView { ...@@ -37,7 +31,7 @@ class SkikoUIView : UIView {
private lateinit var _redrawer: MetalRedrawer private lateinit var _redrawer: MetalRedrawer
init { init {
multipleTouchEnabled = true skikoInitializeUIView()
opaque = false // For UIKit interop through a "Hole" opaque = false // For UIKit interop through a "Hole"
_metalLayer.also { _metalLayer.also {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment