How it works

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 a dev.sajidali.*-published tvOS available-at variant onto the official org.jetbrains.* umbrella module for every covered group, 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.

Shared source sets and the visibility identity gate

Injecting a tvOS variant onto the official umbrella (above) is enough for a single-target configuration like tvosArm64CompileKlibraries to resolve and compile correctly. Before 1.4.0 it was not enough for a shared source set spanning tvOS and another platform (commonMain in an iosArm64 + tvosArm64 project, appleMain, etc.): compileCommonMainKotlinMetadata could fail with hundreds of “Unresolved reference” errors for Compose symbols pulled in only transitively (e.g. commonMain depends on foundation, which depends on ui, and androidx.compose.ui.Modifier lives in ui) even though the equivalent single-target tvOS compile succeeded.

The root cause is not stale or incorrect metadata content. Kotlin Gradle Plugin’s granular metadata transformation (SourceSetVisibilityProvider.getVisibleSourceSets) decides which source sets of a dependency a shared source set may see by looking the dependency up — by the selected component’s group:module identity — in the resolved compile classpath of every platform compilation that shared source set participates in, and returns “nothing visible” the moment even one participating platform compilation lacks that identity entirely. It never gets as far as consulting the dependency’s kotlin-project-structure-metadata.json, so rewriting that file cannot fix this.

Before 1.4.0, the injected tvOS variant’s dependency pointed straight at the fork’s own tvOS platform module (dev.sajidali.compose.ui:ui-tvosarm64), whose own dependencies in turn named other fork umbrellas (dev.sajidali.compose.ui:ui-graphics, and so on). The iOS/JVM/JS compilations in the same shared source set, meanwhile, resolved the genuine official umbrellas (org.jetbrains.compose.ui:ui-graphics). Same library, two different group:module identities across the participating platforms — so the visibility gate dropped ui-graphics, and everything transitively reachable only through it, from commonMain’s metadata classpath.

Since 1.4.0, the same ComponentMetadataRule also rewrites the fork’s own tvOS platform modules: every dependency of a dev.sajidali.* tvOS platform module that points at another fork umbrella is rewritten back to the OFFICIAL umbrella it mirrors (recovering the official version via the same version-mapping machinery described below, and rewriting only once a repository has confirmed the official coordinate exists and is usable for tvOS; since 1.4.2 an inconclusive probe or --offline with nothing cached leaves the edge pointing at the fork umbrella and is reported at end of build naming the coordinate, or fails the build under strictMode). The official umbrella then receives its own injected tvOS variant exactly as if it had been requested directly, so every participating platform compilation ends up resolving the identical org.jetbrains.* node for that dependency — satisfying the identity gate and letting Kotlin’s normal PSM-driven visibility logic take over from there. This only touches modules reachable through the tvOS-specific injected variants, so a consumer’s own directly-declared dev.sajidali.* coordinates, and every non-tvOS platform’s resolution, are unaffected.

Official-first resolution

Before injecting or substituting anything, the plugin checks whether the official artifact you requested already ships a genuine tvOS klib at that exact group:artifact:version — this has started happening upstream for some modules, e.g. org.jetbrains.compose.runtime. If it does, the plugin leaves it alone: no dev.sajidali coordinate is ever introduced for that dependency. This applies both to the metadata-rule injection path and to the separate project-level dependency-substitution path.

This is what lets the plugin keep working transparently as JetBrains ships more native tvOS support upstream over time — a module that gains an official tvOS klib simply stops being redirected, with no consumer-side change needed.

org.jetbrains.compose plugin-marker interception

plugins { id("org.jetbrains.compose") } in a project build script is transparently substituted (via pluginManagement.resolutionStrategy.eachPlugin) to the tvOS-patched dev.sajidali.compose:compose-gradle-plugin fork, so tvOS Compose Resources packaging works with no consumer-side plugin-id change.

Version resolution (first non-null wins):

  1. composeTvos.composeGradlePluginVersion
  2. the version-manifest’s gradlePlugin field
  3. the requested org.jetbrains.compose version (the same-version convention, below)

Opt out entirely with:

composeTvos {
    interceptComposeGradlePlugin.set(false)
}

Opting out means the official, unpatched Gradle plugin is resolved instead — tvOS Compose Resources packaging will not work.

The same-version convention

Every tvOS-fork artifact is published at the exact same version as the official artifact you requested. For every org.jetbrains.X:Y:V the fork publishes dev.sajidali.X:Y:V, differing only by the added tvOS klibs. That is the whole resolution rule: the version you ask for is the version you get.

There are no version mappings, and there must not be any. A request for 1.12 resolves to 1.12; a request for a version the fork has not published fails and names the coordinate, rather than resolving to a different version. Cross-version substitution is forbidden because your tvOS target would then compile against a different API surface than every other target in the same source set, and substituting an alpha build for a stable request is worse still.

Compose Multiplatform 1.12.0 is the supported floor. Older Compose lines are not back-published.

The plugin still fetches a remote JSON manifest (manifestUrl, defaulting to manifest/compose-tvos-versions.json on the plugin repository’s main branch), for one non-version reason. Its mappings object is empty and stays empty; its gradlePlugin field pins which dev.sajidali.compose:compose-gradle-plugin the plugin-marker interception substitutes (see above). That field is single-valued precisely because only one Compose line is supported.

composeTvos.versionMappings still exists as an escape hatch and wins over the manifest on key collision, but using it re-introduces exactly the cross-version substitution described above. Set manifestUrl to "" to disable manifest fetching entirely, which also disables plugin-marker interception’s version pin.

Diagnostics: strictMode and verbose

By default, a redirect-eligible module that resolves to zero tvOS variants (i.e. the plugin looked, but neither the official artifact nor the dev.sajidali fork has a tvOS klib for the requested version) is reported as an end-of-build WARNING block, not a build failure — some of these are pre-conflict-resolution candidate versions Gradle never actually consumes (see Troubleshooting), so failing on every one would be noisy.

Set composeTvos.strictMode.set(true) to turn that block into a hard GradleException naming 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.

composeTvos.verbose.set(true) logs variant discovery, dependency redirection, repository lookups, and manifest loading at lifecycle level.

Configuration reference

// settings.gradle.kts
plugins {
    id("dev.sajidali.compose-tvos") version "1.4.2"
}

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")

    // Escape hatch only. The fork publishes JetBrains' exact versions, so this should be
    // unnecessary; anything you put here is a cross-version substitution (see the policy above).
    // versionMappings.put("org.jetbrains.compose.material3:<requested>", "<fork version>")

    // 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.
    // (androidx.tv, io.insert-koin and io.coil-kt.coil3 are already defaults)
    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")
}