Build Setup: Targets, Source Sets and buildSrc for tvOS in a Compose Multiplatform Fork

Compose Multiplatform on tvOS

This series: Compose Multiplatform on tvOS

  1. My Journey Making Compose Multiplatform Work on tvOS, and What I Learned
  2. Build Setup: Targets, Source Sets and buildSrc for tvOS in a Compose Multiplatform Fork (this post)
  3. Rendering (coming soon)
  4. Siri Remote input (coming soon)
  5. Siri Remote trackpad (coming soon)
  6. Screen density and text input (coming soon)
  7. Porting tv-material (coming soon)
  8. The Gradle plugin and third-party libraries (coming soon)
  9. Maintaining the fork (coming soon)
  10. Building a real app on it (coming soon)

Part 1 was why this fork exists. This one is the build layer, and it comes before any Compose code, because in Kotlin Multiplatform a target is not a flag you flip at the end. It decides which source sets compile, which dependencies resolve, which klibs get published, which linker flags get passed. Until the build knows about tvosArm64, nothing tells you what is missing.

So the first commits were build files. Once the targets were on, the compiler listed what was missing, module by module, and I worked through that list.

Source set layout on the tvos branch

I started on a branch called tvos, based on upstream. Back then JetBrains called iOS “uikit” in this repository and the source set was uikitMain. Because tvOS is also UIKit underneath, it made sense to have a common source parent for both iOS and tvOS. But iOS was already being called uikit, so I chose uiKitCommonMain as the source set name and moved all the UIKit files there, keeping only the iOS specific files in uikitMain and putting the tvOS specific files in a new source set, tvosMain.

The problem with this approach was how to keep up with upstream. I tried a symlink to the uikit source set, and then overriding the tvOS files in a separate target. The override worked but complicated the process. At that time my goal was to make it runnable on tvOS however possible, so we would have a proof of concept. So I did a lot of hacks in the tvos branch to make it runnable, and after about a week it ran on an Apple TV.

It was only after JetBrains renamed uikit to ios that I created a new main branch, tvos-main, to track upstream jb-main.

The uiKitMain layer on tvos-main

This is the layout on tvos-main today. There is no central declaration of it anywhere, each relevant module creates it in its own build file:

// compose/ui/ui/build-fork.gradle
uiKitMain {
dependsOn(nativeMain)
dependencies {
implementation(project(":compose:ui:ui-uikit"))
}
}

iosMain {
dependsOn(uiKitMain)
}

tvosMain {
dependsOn(uiKitMain)
}

uiKitMain sits between nativeMain and the two platform source sets. Anything that is plain UIKit lives there once and both platforms get it. iosMain keeps what only iOS has: hover, drag and drop, the inline keyboard, the text loupe. tvosMain keeps what only tvOS has: the Siri Remote, the focus engine bridge, the full screen keyboard, the density rule. compose/foundation/foundation/build-fork.gradle has the same shape.

The alternative was a tvosMain that copies the iOS implementation. That builds just as well on day one, but every upstream fix to the iOS rendering path would then have to be applied twice, by hand, forever. With uiKitMain, an upstream change to shared UIKit code lands once and tvOS gets it too. Maintenance cost scales with how much code sits in tvosMain, so keeping that source set small is the main design constraint.

What upstream already had

compose-multiplatform-core is AndroidX with JetBrains’ multiplatform work on top, and it has its own idea of a platform, separate from Kotlin’s. A ComposePlatforms enum drives policy: which platforms get Skiko, which get published together, which count as Darwin. tvOS was already in there.

// buildSrc-fork/public/src/main/kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt
TvosArm64("TvOs"),
TvosSimulatorArm64("TvOs"),
...
val TV_OS = EnumSet.of(TvosArm64, TvosSimulatorArm64)
...
val DARWIN = IOS + WATCH_OS + TV_OS + MACOS_NATIVE

DARWIN already included TV_OS, so tvOS already counted as an Apple platform. The tvos(), tvosArm64() and tvosSimulatorArm64() DSL functions, the ones a module calls to register its targets, came down from AndroidX in June 2024, 01549107. All of that predates the fork.

Around forty upstream modules already support tvos() including annotation, collection, kruth, the compose/runtime group, every lifecycle module, navigation, navigation3, navigationevent, room3, savedstate, window/window-core, the testutils modules. Those klibs were being built and published for tvOS before I touched anything.

No UI module declared a tvOS target, and none could have, because upstream’s SKIKO_SUPPORT reads EnumSet.of(KotlinMultiplatform) + JVM_BASED + IOS + MACOS_NATIVE + WEB, with no TV_OS in it. That omission is what kept ui, foundation, material and every other Skiko backed module off tvOS.

So the fork’s change in the build logic is one line: SKIKO_SUPPORT gets + TV_OS, so the build expects Skiko binaries for tvOS, which only works because Skiko already publishes them. A second line in JetBrainsPublication.kt puts ComposePlatforms.TV_OS next to iOS in the supported platform list for ui-uikit, so the artifacts get published instead of built and thrown away. Everything else is tvos()calls in each module’s build file, sitting next to ios().

buildSrc and buildSrc-fork

I got the targets wired up in April 2026 and put those build logic edits in buildSrc/, because that was the only copy of the build logic in the repository at the time.

On 29 June 2026, a702a011, upstream split it. buildSrc-fork/ appears as a copy of buildSrc/, and buildSrc/settings.gradle now redirects :private, :public, :plugins and :tests over there whenever PROJECT_MODE is not AOSP, which is the default. So buildSrc-fork is the copy that actually compiles, and buildSrc is what you get in AOSP mode. The naming points the wrong way.

I wired the tvOS targets into the build-fork.gradle files as a result f8281d7b, and mirrored the publishing edits into buildSrc-fork/, 93d12136. The repository keeps two copies of its build logic, so every edit there is two edits, and a missed mirror does not fail the build.

build-fork.gradle

The file name in the snippets above is build-fork.gradle, not build.gradle. That mechanism was already in the repository. The checkout builds in one of two modes, fork mode is the default, and in fork mode every included project picks its build file from a priority list:

// settings-fork.gradle
project.buildFileName = ["build-fork.gradle.kts", "build-fork.gradle", "build.gradle.kts", "build.gradle"]
.find {
new File(project.projectDir, it).isFile()
}

If a module has a build-fork.gradle, Gradle uses it and ignores build.gradle entirely. If it does not, nothing changes. There are 67 of these in the repository now, across compose/*, lifecycle/*, navigation/*, tv/tv-material, window/window-core and others.

This matters for a fork that has to rebase. The upstream build.gradle files stay byte identical to what AOSP and JetBrains ship. When upstream changes a build file, the rebase applies it with no conflict, because I never touched that file, and my changes are in a sibling file that does not exist upstream. Most of the tvos() calls and all of the uiKitMain wiring live in files that structurally cannot conflict.

ui-uikit: SDKs, destinations and the generated .def

compose:ui:ui-uikit is the small Objective-C and Swift helper library I described at the end of Part 1, the one Compose calls through cinterop. Its build file is the main place where Gradle talks to Xcode directly, and it was written assuming iPhone.

Everything routes through one private configure function, so adding tvOS meant giving it a platform parameter with a default and passing it from the two new targets:

// compose/ui/ui-uikit/build.gradle
tvosArm64("tvosArm64") {
configure(it, true, "arm64", null /*"tvosArm64Test"*/, "tvOS")
}
tvosSimulatorArm64("tvosSimulatorArm64") {
configure(it, false, "arm64", "tvosSimulatorArm64Test", "tvOS")
}

Inside, that platform string picks the SDK name prefix and the xcodebuild destination:

// compose/ui/ui-uikit/build.gradle
private def configure(target, isDevice, architecture, testTarget, platform = "iOS") {
if (!isMacHost()) return
def sdkPrefix = (platform == "tvOS") ? "appletv" : "iphone"
def sdkName = isDevice ? "${sdkPrefix}os" : "${sdkPrefix}simulator"
def destination = isDevice ? "generic/platform=${platform}" : "generic/platform=${platform} Simulator"

That gives you appletvos, appletvsimulator and generic/platform=tvOS Simulator. One more place hardcoded iPhone, the device name pattern the test task matches when it lists simulators, so that becomes Apple TV on tvOS and stays iPhone 1[567] otherwise.

Those configure calls pass the test target name as the fourth argument. The simulator target passes one, and the device target passes null /*”tvosArm64Test”*/, which follows the pattern iOS already had, null /*”iosArm64Test”*/. Device tests are not wired up for either platform in this module. Simulator tests run, device builds compile and link, and that is how far verification goes here.

The linker options at the end of the file needed the same kind of parameterization. Kotlin’s cinterop lets you pass linker options, but it only keeps them if they arrive from a .def file, and options passed any other way never reach the link step. Swift compatibility libraries live under the selected Xcode toolchain in a directory named after the SDK, so the path differs between appletvos and appletvsimulator. The build generates a .def per target:

// compose/ui/ui-uikit/build.gradle
def swiftToolchainLibPath = new File(swiftToolchainDir, "usr/lib/swift/${sdkName}")
def swiftLinkerOpts = [
"-ObjC",
"-L${swiftToolchainLibPath}",

This needed no tvOS specific code. It is keyed off sdkName, which was already a variable, so parameterizing the SDK name at the top of the file was enough.

The pbxproj

The helper library is a real Xcode project, so it has a project.pbxproj, and two settings in there decide whether tvOS is buildable at all. SUPPORTED_PLATFORMS has to list the tvOS SDKs, and TARGETED_DEVICE_FAMILY has to include 3, which is Apple TV, next to 1 and 2 for iPhone and iPad:

// compose/ui/ui-uikit/src/iosMain/objc/CMPUIKitUtils/CMPUIKitUtils.xcodeproj/project.pbxproj
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator";
TARGETED_DEVICE_FAMILY = "1,2,3";

These settings are per build configuration. There are ten configurations in that file, across four targets plus the project itself. I set SUPPORTED_PLATFORMS on the two project level ones so everything inherits the tvOS SDKs. I set TARGETED_DEVICE_FAMILY = “1,2,3” on six configurations.

Resources: decoding the SDK name

The second repository is compose-multiplatform, home of the Compose Gradle plugin. For tvOS it needed one thing: resources.

When an Apple app builds, a Gradle task runs as an Xcode build phase and copies the Compose resources into the app bundle. It has to know which Kotlin target it is packaging for, and all it gets is Xcode’s environment: a platform name and a list of architectures. So the plugin decodes them:

// gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/IosResourcesTasks.kt
platform.startsWith("appletvos") -> {
targets.addAll(archs.map { arch ->
when (arch) {
"arm64", "arm64e" -> KonanTarget.TVOS_ARM64
else -> error("Unknown tvOS device arch: '$arch'")
}
})
}

The same branch handles appletvsimulator. The predicate that decides whether a target is one the plugin handles at all also had to widen: isIosOrMacTarget now ends with || isTvosTarget(). I opened a PR upstream for this but unfortunately it hasn’t been reviewed yet.

What’s next

With the build layer in place the targets compile, and the compiler starts reporting what is actually missing. Most of that lives in rendering, which is Part 3: how Compose hosts itself inside a UIViewController on tvOS, ComposeSceneMediator and the hosting view, layers, Skiko and Metal, frame scheduling, and why these particular files are the ones that fight back on every upstream rebase.

Links


Build Setup: Targets, Source Sets and buildSrc for tvOS in a Compose Multiplatform Fork was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.