A Gradle settings plugin that adds tvOS (tvosArm64/tvosSimulatorArm64) support to JetBrains
Compose Multiplatform projects, without JetBrains's own artifacts needing to support tvOS yet.
Full documentation: https://sajidalidev.github.io/compose-tvos/
// settings.gradle.kts
plugins {
id("dev.sajidali.compose-tvos") version "1.1.0"
}That's it. Add tvosArm64()/tvosSimulatorArm64() targets to your Kotlin Multiplatform module as
you normally would and compose.runtime/compose.foundation/compose.material3/
compose.components.resources (and the other covered groups below) resolve for tvOS too.
JetBrains Compose Multiplatform does not yet officially ship tvOS klibs for most of its modules.
This plugin closes that gap at dependency-resolution time, in your build, with no changes to
your dependencies {} declarations:
- tvOS variant injection. A
ComponentMetadataRule(TvosVariantInjectionRule) attaches adev.sajidali.*-published tvOSavailable-atvariant onto the officialorg.jetbrains.*umbrella module for every covered group (see below), so Gradle's own variant resolution picks the tvOS artifact for tvOS targets while iOS/Android/Desktop targets keep resolving the official JetBrains artifact untouched. - Official-first. Before injecting or substituting anything, the plugin checks whether the
official artifact you requested already ships a genuine tvOS
klibat that exactgroup:artifact:version(this has started happening upstream for some modules, e.g.org.jetbrains.compose.runtime). If it does, the plugin leaves it alone — nodev.sajidalicoordinate is ever introduced for that dependency. This applies to both the metadata-rule injection path and the separate project-level dependency-substitution path. org.jetbrains.composeplugin-marker interception.plugins { id("org.jetbrains.compose") }in a project build script is transparently substituted (viapluginManagement.resolutionStrategy.eachPlugin) to the tvOS-patcheddev.sajidali.compose:compose-gradle-pluginfork, so tvOS Compose Resources packaging works with no consumer-side plugin-id change. Version resolution (first non-null wins):composeTvos.composeGradlePluginVersion→ the manifest'sgradlePluginfield → the requestedorg.jetbrains.composeversion (same-version convention). Opt out entirely with:composeTvos { interceptComposeGradlePlugin.set(false) }- Version-mapping manifest. Most tvOS-fork artifacts are published at the exact same version
as the official artifact you requested (the "same-version convention"). Where that isn't true
— the fork tracks a different alpha/beta line, or hasn't republished a given upstream version —
the plugin consults a remote JSON manifest (
manifestUrl, defaults tomanifest/compose-tvos-versions.jsonin this repo onmain) for an explicit override. The manifest is schema-versioned ("schema": 2); agradlePluginfield (schema 2+) additionally pins the plugin-marker interception's default version.composeTvos.versionMappingsentries you set yourself always win over the manifest on key collision. SetmanifestUrlto""to disable manifest fetching entirely and rely solely on your ownversionMappings/the same-version convention. strictMode. By default, a redirect-eligible module that resolves to zero tvOS variants (i.e., the plugin looked, but neither the official artifact nor thedev.sajidalifork has a tvOSklibfor the requested version) is reported as an end-of-buildWARNINGblock, not a build failure — some of these are pre-conflict-resolution candidate versions Gradle never actually consumes (see Troubleshooting below), so failing on every one would be noisy. SetcomposeTvos.strictMode.set(true)to turn that block into a hardGradleExceptionnaming every affected module instead. Both only ever fire for a project that actually declares a tvOS Kotlin target — an iOS/Android/Desktop-only project is never affected.verbose.composeTvos.verbose.set(true)logs variant discovery, dependency redirection, repository lookups, and manifest loading atlifecyclelevel.- Offline behavior. With
--offline, the plugin never opens a network connection: the version manifest and per-artifact variant-discovery results are served from their on-disk caches only (fresh or stale — stale-while-offline is preferred over failing the build), and a coordinate with no cached entry degrades to an empty result (treated the same as "no tvOS variant found"), never a hard failure.--refresh-dependenciesforces a fresh manifest fetch (variant-discovery caching is unaffected by that flag). - Caches live under
GRADLE_USER_HOME. Both the variant-discovery cache (<gradleUserHome>/compose-tvos-redirect-cache-v4/) and the version-manifest cache (<gradleUserHome>/compose-tvos-redirect-cache-v4/version-manifest/) are resolved from whicheverGRADLE_USER_HOME(or--gradle-user-home) the invoking Gradle actually used — never hardcoded to~/.gradle— so isolated/CI/TestKit invocations get isolated caches automatically.
The plugin redirects these 15 org.jetbrains.* groups to their dev.sajidali.* tvOS-fork twin
whenever a tvOS Kotlin target requests them:
org.jetbrains.compose.uiorg.jetbrains.compose.foundationorg.jetbrains.compose.runtimeorg.jetbrains.compose.materialorg.jetbrains.compose.material3org.jetbrains.compose.animationorg.jetbrains.compose.componentsorg.jetbrains.compose.annotation-internalorg.jetbrains.compose.collection-internalorg.jetbrains.compose.material3.adaptive(adaptive,adaptive-layout,adaptive-navigation,adaptive-navigation3) andmaterial3-adaptive-navigation-suite— published by the fork, includingandroidx.window:window-core, which the fork now builds a real tvOS Kotlin/Native target for. No extra configuration needed: the same-version convention coversadaptive, andwindow-corearrives transitively through the fork's own module metadata.org.jetbrains.androidx.navigation(including thenavigation-composeartifact)org.jetbrains.androidx.lifecycle(includinglifecycle-viewmodel-compose)org.jetbrains.androidx.savedstate(includingsavedstate-compose)org.jetbrains.androidx.navigationeventorg.jetbrains.androidx.navigation3(includingnavigation3-ui)
Add further groups/artifacts of your own with composeTvos.additionalGroups/
additionalArtifacts (see Configuration below) — useful for third-party KMP libraries that
publish their own tvOS-less umbrella artifacts the same way JetBrains does.
The fork republishes upstream Compose Multiplatform/AndroidX libraries under dev.sajidali.*
with tvOS klibs added. Versions currently published (verify against the manifest / your own
versionMappings for anything not covered by the same-version convention):
| Component | Published dev.sajidali version |
|---|---|
compose.{ui,foundation,runtime,animation,material,components} |
1.12.0-beta01 |
compose.material3 (own alpha line, independent of the COMPOSE version) |
1.5.0-alpha22 |
androidx.lifecycle.* |
2.11.0 |
androidx.navigation.* (incl. navigation-compose) |
2.10.0-alpha05 |
androidx.navigation3.* (navigation3-ui) |
1.2.0-alpha04 |
androidx.navigationevent.* (navigationevent-compose) |
1.1.1 |
androidx.savedstate.* (incl. savedstate-compose) |
1.5.0-alpha01 |
compose-gradle-plugin (the org.jetbrains.compose fork used by plugin-marker interception) |
1.12.0-beta01 |
compose.material3.adaptive (adaptive, adaptive-layout, adaptive-navigation, adaptive-navigation3) |
1.3.0-beta02 |
material3-adaptive-navigation-suite |
1.5.0-alpha22 |
androidx.window.window-core (transitive dependency of material3.adaptive) |
1.6.0-alpha02 |
- Gradle 8.0+
- Kotlin 2.3.20+ for consumer projects targeting tvOS. The fork's published
klibs were compiled with Kotlin/Native compiler2.3.20(verified directly from klib manifests:compiler_version=2.3.20,abi_version=2.3.0) — an older Kotlin version's compiler will not be ABI-compatible with these artifacts on tvOS targets. Non-tvOS targets in the same project are unaffected by this constraint (they never touch adev.sajidaliartifact). - JetBrains Compose Multiplatform 1.6+
The plugin works out of the box with sensible defaults. Full optional configuration surface:
// settings.gradle.kts
plugins {
id("dev.sajidali.compose-tvos") version "1.1.0"
}
composeTvos {
// Enable verbose logging (variant discovery, redirection, manifest loading).
verbose.set(true)
// Fail the build (instead of warning) when a tvOS project has a redirect-eligible module
// with zero discoverable tvOS variants.
strictMode.set(true)
// Override target version for all redirected artifacts.
targetVersion.set("1.12.0-beta01")
// Add/override version mappings; always wins over the remote manifest on key collision.
versionMappings.put("org.jetbrains.compose.material3:1.11.0-alpha07", "1.5.0-alpha22")
// Point at a different (or empty, to disable) version-mapping manifest.
manifestUrl.set("https://raw.githubusercontent.com/sajidalidev/compose-tvos/main/manifest/compose-tvos-versions.json")
// Add additional library groups to redirect.
additionalGroups.put("io.insert-koin", "dev.sajidali.koin")
// Add specific artifact mappings.
additionalArtifacts.put(
"io.coil-kt.coil3:coil-compose",
"dev.sajidali.coil3:coil-compose"
)
// Opt out of the org.jetbrains.compose plugin-marker interception (resolve the official,
// unpatched Gradle plugin instead — tvOS Compose Resources packaging will not work).
interceptComposeGradlePlugin.set(false)
// Override the version of the substituted compose-gradle-plugin fork (only relevant when
// interceptComposeGradlePlugin is true).
composeGradlePluginVersion.set("1.12.0-beta01")
}demo/ is a working, canonical tvOS Compose Multiplatform consumer of this plugin:
compose.runtime/foundation/material3/components.resources, navigation-compose,
lifecycle-viewmodel-compose, tvOS + iOS targets, and Compose Resources. It builds this plugin
as a composite build (includeBuild("..")) so it always tracks the branch under development
rather than a published coordinate — see the comments at the top of demo/settings.gradle.kts
for how a real consumer's setup differs (a plain version-pinned plugins {} block, no
includeBuild).
Building your KMP module produces a Kotlin framework per tvOS target (e.g.
:linkDebugFrameworkTvosSimulatorArm64 → <module>.framework), which exposes your
UIViewController-returning entry point (e.g. ComposeUIViewController { App() }) to Swift/
Objective-C. A static framework (isStatic = true) links directly into the app executable with
no separate embedding step; a dynamic framework needs the usual Frameworks/ embedding.
Compose Resources bundle layout. Gradle assembles resources per target under
build/generated/compose/resourceGenerator/assembledResources/<target>Main/composeResources/
(camelCase, no hyphen), and the tvOS resource reader looks for them at runtime nested one level
inside a hyphenated compose-resources/ wrapper directory at the app bundle root — the
assembled composeResources/ tree itself is copied in as-is, not renamed and not flattened. For
example, an app bundled as Demo.app with a demo.generated.resources package must end up with:
Demo.app/compose-resources/composeResources/demo.generated.resources/drawable/logo.xml
Demo.app/compose-resources/composeResources/demo.generated.resources/values/strings.commonMain.cvr
i.e. copy the assembled composeResources/ directory as-is into a compose-resources/
directory you create at the bundle root:
mkdir -p Demo.app/compose-resources
cp -R build/generated/compose/resourceGenerator/assembledResources/tvosSimulatorArm64Main/composeResources \
Demo.app/compose-resources/This nesting is easy to get wrong and isn't obvious from the assembled directory name alone.
Hand-assembled bundles (e.g. CI smoke tests or tooling that builds a .app without an Xcode
project) must replicate this layout explicitly; a naive copy/rename produces a black screen or
a missing-resource exception.
This means the plugin looked for a tvOS klib (official first, then the dev.sajidali fork) for
one or more modules and found neither, for the exact group:artifact:version it was asked
about. Two common causes:
- A real gap — the fork hasn't published that version/group yet (check the version matrix
above and the manifest), or the version needs an entry in
composeTvos.versionMappings. - A pre-conflict-resolution candidate version.
ComponentMetadataRules fire on every version node Gradle's resolution engine touches while resolving a graph, not just the winning version after conflict resolution — so a losing candidate version (one nothing ends up actually depending on) can legitimately show up in this list even though your build's resolved graph never touches it. If your build otherwise succeeds and the modules named don't ring a bell, this is very likely why. SetcomposeTvos.strictMode.set(true)temporarily and inspect the resulting exception message together with./gradlew :dependencies --configuration <tvosConfigurationName>to see whether the named module survives conflict resolution.
Use composeTvos.strictMode.set(true) if you'd rather the build fail loudly than warn — useful in
CI once you've confirmed your resolved graph is clean.
If a fork publish changes shape (new tvOS variants, a version bump) and the plugin still reports
stale results, clear the on-disk caches under GRADLE_USER_HOME:
rm -rf "$(./gradlew properties -q --property gradleUserHomeDir | awk '{print $2}')/compose-tvos-redirect-cache-v4"or simply delete ~/.gradle/compose-tvos-redirect-cache-v4 if you use the default
GRADLE_USER_HOME. --refresh-dependencies forces a fresh version-manifest fetch without
clearing the variant-discovery cache.
Ensure your repositories include Maven Central (and, for the very latest fork publishes ahead of
a Central sync, mavenLocal()):
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Copyright 2025 Sajid Ali
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.