<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sajid Ali</title>
    <description>The latest articles on DEV Community by Sajid Ali (@sajidalidev).</description>
    <link>https://dev.to/sajidalidev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1704408%2F9a27c917-3ea4-4edd-9799-1b8b82a0c787.jpg</url>
      <title>DEV Community: Sajid Ali</title>
      <link>https://dev.to/sajidalidev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sajidalidev"/>
    <language>en</language>
    <item>
      <title>Build Setup: Targets, Source Sets and buildSrc for tvOS in a Compose Multiplatform Fork</title>
      <dc:creator>Sajid Ali</dc:creator>
      <pubDate>Thu, 17 Sep 2026 13:46:13 +0000</pubDate>
      <link>https://dev.to/sajidalidev/build-setup-targets-source-sets-and-buildsrc-for-tvos-in-a-compose-multiplatform-fork-4nbf</link>
      <guid>https://dev.to/sajidalidev/build-setup-targets-source-sets-and-buildsrc-for-tvos-in-a-compose-multiplatform-fork-4nbf</guid>
      <description>&lt;p&gt;&lt;em&gt;This series: Compose Multiplatform on tvOS&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/sajidaledev/my-journey-making-compose-multiplatform-work-on-tvos-and-what-i-learned-1mo3"&gt;My Journey Making Compose Multiplatform Work on tvOS, and What I Learned&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build Setup: Targets, Source Sets and buildSrc for tvOS in a Compose Multiplatform Fork&lt;/strong&gt; (this post)&lt;/li&gt;
&lt;li&gt;Rendering (coming soon)&lt;/li&gt;
&lt;li&gt;Siri Remote input (coming soon)&lt;/li&gt;
&lt;li&gt;Siri Remote trackpad (coming soon)&lt;/li&gt;
&lt;li&gt;Screen density and text input (coming soon)&lt;/li&gt;
&lt;li&gt;Porting tv-material (coming soon)&lt;/li&gt;
&lt;li&gt;The Gradle plugin and third-party libraries (coming soon)&lt;/li&gt;
&lt;li&gt;Maintaining the fork (coming soon)&lt;/li&gt;
&lt;li&gt;Building a real app on it (coming soon)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;code&gt;tvosArm64&lt;/code&gt;, nothing tells you what is missing.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source set layout on the tvos branch
&lt;/h2&gt;

&lt;p&gt;I started on a branch called &lt;code&gt;tvos&lt;/code&gt;, based on upstream. Back then JetBrains called iOS "uikit" in this repository and the source set was &lt;code&gt;uikitMain&lt;/code&gt;. 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 &lt;code&gt;uiKitCommonMain&lt;/code&gt; as the source set name and moved all the UIKit files there, keeping only the iOS specific files in &lt;code&gt;uikitMain&lt;/code&gt; and putting the tvOS specific files in a new source set, &lt;code&gt;tvosMain&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;tvos&lt;/code&gt; branch to make it runnable, and after about a week it ran on an Apple TV.&lt;/p&gt;

&lt;p&gt;It was only after JetBrains renamed uikit to ios that I created a new main branch, &lt;code&gt;tvos-main&lt;/code&gt;, to track upstream &lt;code&gt;jb-main&lt;/code&gt;. The rename came in two commits: on 5 December 2025 the &lt;code&gt;Uikit*&lt;/code&gt; targets were removed in favour of &lt;code&gt;Ios*&lt;/code&gt; (&lt;a href="https://github.com/JetBrains/compose-multiplatform-core/commit/602b39cc318b6a4525ba4b870fcab9dc3bad9442" rel="noopener noreferrer"&gt;&lt;code&gt;602b39cc&lt;/code&gt;&lt;/a&gt;, #2545), and six days later &lt;code&gt;uikitMain&lt;/code&gt; became &lt;code&gt;iosMain&lt;/code&gt; and every shared file got the &lt;code&gt;.ios.kt&lt;/code&gt; suffix (&lt;a href="https://github.com/JetBrains/compose-multiplatform-core/commit/9fe1485e7829dd3f13f84af435bc26925ac42a60" rel="noopener noreferrer"&gt;&lt;code&gt;9fe1485e&lt;/code&gt;&lt;/a&gt;, #2639). On &lt;code&gt;tvos-main&lt;/code&gt; I renamed &lt;code&gt;uiKitCommonMain&lt;/code&gt; to &lt;code&gt;uiKitMain&lt;/code&gt;, as the name was now available (&lt;a href="https://github.com/sajidalidev/compose-multiplatform-core/commit/4b6896f28f1dff15021fdab1148837b8be6dde63" rel="noopener noreferrer"&gt;&lt;code&gt;4b6896f2&lt;/code&gt;&lt;/a&gt;). &lt;code&gt;tvosMain&lt;/code&gt; had come in a few days earlier with the Siri Remote input work (&lt;a href="https://github.com/sajidalidev/compose-multiplatform-core/commit/e57f35dfff9ad7454b39f01ae3dc3a6b5a112902" rel="noopener noreferrer"&gt;&lt;code&gt;e57f35df&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The uiKitMain layer on tvos-main
&lt;/h2&gt;

&lt;p&gt;This is the layout on &lt;code&gt;tvos-main&lt;/code&gt; today. There is no central declaration of it anywhere, each relevant module creates it in its own build file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="c1"&gt;// compose/ui/ui/build-fork.gradle&lt;/span&gt;
&lt;span class="n"&gt;uiKitMain&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dependsOn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nativeMain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dependencies&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;implementation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;":compose:ui:ui-uikit"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;iosMain&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dependsOn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiKitMain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;tvosMain&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dependsOn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiKitMain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The alternative was a &lt;code&gt;tvosMain&lt;/code&gt; 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 &lt;code&gt;uiKitMain&lt;/code&gt;, an upstream change to shared UIKit code lands once and tvOS gets it too. Maintenance cost scales with how much code sits in &lt;code&gt;tvosMain&lt;/code&gt;, so keeping that source set small is the main design constraint.&lt;/p&gt;

&lt;p&gt;This has already paid off on a rebase. On 25 August 2026, &lt;a href="https://github.com/sajidalidev/compose-multiplatform-core/commit/91ff7dde8ba1961ecc69535e96c0680d6d88e8c9" rel="noopener noreferrer"&gt;&lt;code&gt;91ff7dde&lt;/code&gt;&lt;/a&gt;, upstream's later &lt;code&gt;UIKit*&lt;/code&gt; to &lt;code&gt;Ios*&lt;/code&gt; class and file renames came through as a three way merge over the 20 renamed files the fork shares through &lt;code&gt;uiKitMain&lt;/code&gt;, and needed no further work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What upstream already had
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;compose-multiplatform-core&lt;/code&gt; is AndroidX with JetBrains' multiplatform work on top, and it has its own idea of a platform, separate from Kotlin's. A &lt;code&gt;ComposePlatforms&lt;/code&gt; enum drives policy: which platforms get Skiko, which get published together, which count as Darwin.&lt;/p&gt;

&lt;p&gt;tvOS was already in there, and it had been for years. Nikolai Rykunov added the &lt;code&gt;TvosArm64&lt;/code&gt;, &lt;code&gt;TvosX64&lt;/code&gt; and &lt;code&gt;TvosSimulatorArm64&lt;/code&gt; entries on 18 April 2023 in &lt;a href="https://github.com/JetBrains/compose-multiplatform-core/commit/d4b2366ba56e8cafa7ce5b2e31c9e2b98c0d62cb" rel="noopener noreferrer"&gt;&lt;code&gt;d4b2366b&lt;/code&gt;&lt;/a&gt;. Ivan Matkov grouped them into the &lt;code&gt;TV_OS&lt;/code&gt; set on 18 April 2024 in &lt;a href="https://github.com/JetBrains/compose-multiplatform-core/commit/98eb0146def2b6254d50cec14f1dc45808edd67c" rel="noopener noreferrer"&gt;&lt;code&gt;98eb0146&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// buildSrc-fork/public/src/main/kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt&lt;/span&gt;
&lt;span class="nc"&gt;TvosArm64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TvOs"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nc"&gt;TvosSimulatorArm64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TvOs"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;TV_OS&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EnumSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TvosArm64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TvosSimulatorArm64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;DARWIN&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IOS&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;WATCH_OS&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;TV_OS&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;MACOS_NATIVE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DARWIN&lt;/code&gt; already included &lt;code&gt;TV_OS&lt;/code&gt;, so tvOS already counted as an Apple platform. The &lt;code&gt;tvos()&lt;/code&gt;, &lt;code&gt;tvosArm64()&lt;/code&gt; and &lt;code&gt;tvosSimulatorArm64()&lt;/code&gt; DSL functions, the ones a module calls to register its targets, came down from AndroidX in June 2024, &lt;a href="https://github.com/JetBrains/compose-multiplatform-core/commit/0154910724cdc44253af1d2f8cede76264783226" rel="noopener noreferrer"&gt;&lt;code&gt;01549107&lt;/code&gt;&lt;/a&gt;. All of that predates the fork.&lt;/p&gt;

&lt;p&gt;Around forty leaf modules already call &lt;code&gt;tvos()&lt;/code&gt; upstream: &lt;code&gt;annotation&lt;/code&gt;, &lt;code&gt;collection&lt;/code&gt;, &lt;code&gt;kruth&lt;/code&gt;, the &lt;code&gt;compose/runtime&lt;/code&gt; group, every &lt;code&gt;lifecycle&lt;/code&gt; module, &lt;code&gt;navigation&lt;/code&gt;, &lt;code&gt;navigation3&lt;/code&gt;, &lt;code&gt;navigationevent&lt;/code&gt;, &lt;code&gt;room3&lt;/code&gt;, &lt;code&gt;savedstate&lt;/code&gt;, &lt;code&gt;window/window-core&lt;/code&gt;, the &lt;code&gt;testutils&lt;/code&gt; modules. &lt;code&gt;graphics/graphics-shapes&lt;/code&gt; spells out &lt;code&gt;tvosArm64()&lt;/code&gt; and &lt;code&gt;tvosSimulatorArm64()&lt;/code&gt; instead of the shorthand. Those klibs were being built and published for tvOS before I touched anything.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  buildSrc and buildSrc-fork
&lt;/h2&gt;

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

&lt;p&gt;On 29 June 2026, &lt;a href="https://github.com/JetBrains/compose-multiplatform-core/commit/a702a01181b68946213c6de6804eb1a4956c6f02" rel="noopener noreferrer"&gt;&lt;code&gt;a702a011&lt;/code&gt;&lt;/a&gt;, upstream split it. &lt;code&gt;buildSrc-fork/&lt;/code&gt; appears as a copy of &lt;code&gt;buildSrc/&lt;/code&gt;, and &lt;code&gt;buildSrc/settings.gradle&lt;/code&gt; now redirects &lt;code&gt;:private&lt;/code&gt;, &lt;code&gt;:public&lt;/code&gt;, &lt;code&gt;:plugins&lt;/code&gt; and &lt;code&gt;:tests&lt;/code&gt; over there whenever &lt;code&gt;PROJECT_MODE&lt;/code&gt; is not &lt;code&gt;AOSP&lt;/code&gt;, which is the default. So &lt;code&gt;buildSrc-fork&lt;/code&gt; is the copy that actually compiles, and &lt;code&gt;buildSrc&lt;/code&gt; is what you get in AOSP mode. The naming points the wrong way.&lt;/p&gt;

&lt;p&gt;I wired the tvOS targets into the &lt;code&gt;build-fork.gradle&lt;/code&gt; files on 1 July, &lt;a href="https://github.com/sajidalidev/compose-multiplatform-core/commit/f8281d7bb5846b0d042cf5e1dc16275d958f57f8" rel="noopener noreferrer"&gt;&lt;code&gt;f8281d7b&lt;/code&gt;&lt;/a&gt;, and mirrored the publishing edits into &lt;code&gt;buildSrc-fork/&lt;/code&gt; on 16 July, &lt;a href="https://github.com/sajidalidev/compose-multiplatform-core/commit/93d121364dbf9dfb05dcc0d61eb2b106d484f2a2" rel="noopener noreferrer"&gt;&lt;code&gt;93d12136&lt;/code&gt;&lt;/a&gt;. One change did not get mirrored: the &lt;code&gt;isIOS&lt;/code&gt; to &lt;code&gt;isUIKit&lt;/code&gt; widening in &lt;code&gt;AndroidXForkTargetsExtensions.kt&lt;/code&gt;, which adds the UIKit linker frameworks for tvOS targets. It has been dead code in the default build ever since. Nothing broke, because those are library targets with no final binaries to link, so the flags have nothing to attach to yet. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  build-fork.gradle
&lt;/h2&gt;

&lt;p&gt;The file name in the snippets above is &lt;code&gt;build-fork.gradle&lt;/code&gt;, not &lt;code&gt;build.gradle&lt;/code&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="c1"&gt;// settings-fork.gradle&lt;/span&gt;
&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;buildFileName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"build-fork.gradle.kts"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"build-fork.gradle"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"build.gradle.kts"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"build.gradle"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;find&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;File&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;projectDir&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;isFile&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;This matters for a fork that has to rebase. The upstream &lt;code&gt;build.gradle&lt;/code&gt; 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 &lt;code&gt;tvos()&lt;/code&gt; calls and all of the &lt;code&gt;uiKitMain&lt;/code&gt; wiring live in files that structurally cannot conflict.&lt;/p&gt;

&lt;p&gt;Three modules are the exception. They register their tvOS targets straight into the plain &lt;code&gt;build.gradle&lt;/code&gt; with no fork sibling to hide in: &lt;code&gt;compose/ui/ui-uikit&lt;/code&gt;, &lt;code&gt;compose/ui/ui-backhandler&lt;/code&gt; and &lt;code&gt;compose/ui/ui-skiko&lt;/code&gt;. In &lt;code&gt;ui-uikit&lt;/code&gt; that is unavoidable, the Xcode wiring is in that file and there is nowhere else to put it. Those three are the files I check on every rebase, because they are where upstream and I edit the same lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  ui-uikit: SDKs, destinations and the generated .def
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;compose:ui:ui-uikit&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Everything routes through one private &lt;code&gt;configure&lt;/code&gt; function, so adding tvOS meant giving it a platform parameter with a default and passing it from the two new targets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="c1"&gt;// compose/ui/ui-uikit/build.gradle&lt;/span&gt;
&lt;span class="n"&gt;tvosArm64&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"tvosArm64"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;configure&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"arm64"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="cm"&gt;/*"tvosArm64Test"*/&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"tvOS"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;tvosSimulatorArm64&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"tvosSimulatorArm64"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;configure&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"arm64"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"tvosSimulatorArm64Test"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"tvOS"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside, that platform string picks the SDK name prefix and the xcodebuild destination:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="c1"&gt;// compose/ui/ui-uikit/build.gradle&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isDevice&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;architecture&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testTarget&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"iOS"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;isMacHost&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;sdkPrefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"tvOS"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"appletv"&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"iphone"&lt;/span&gt;
    &lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;sdkName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isDevice&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"${sdkPrefix}os"&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"${sdkPrefix}simulator"&lt;/span&gt;
    &lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isDevice&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"generic/platform=${platform}"&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"generic/platform=${platform} Simulator"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Those &lt;code&gt;configure&lt;/code&gt; calls pass the test target name as the fourth argument. The simulator target passes one, and the device target passes &lt;code&gt;null /*"tvosArm64Test"*/&lt;/code&gt;, which follows the pattern iOS already had, &lt;code&gt;null /*"iosArm64Test"*/&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;.def&lt;/code&gt; 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 &lt;code&gt;appletvos&lt;/code&gt; and &lt;code&gt;appletvsimulator&lt;/code&gt;. The build generates a &lt;code&gt;.def&lt;/code&gt; per target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="c1"&gt;// compose/ui/ui-uikit/build.gradle&lt;/span&gt;
&lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;swiftToolchainLibPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;swiftToolchainDir&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"usr/lib/swift/${sdkName}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;swiftLinkerOpts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;
        &lt;span class="s2"&gt;"-ObjC"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"-L${swiftToolchainLibPath}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  The pbxproj
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;// &lt;span class="n"&gt;compose&lt;/span&gt;/&lt;span class="n"&gt;ui&lt;/span&gt;/&lt;span class="n"&gt;ui&lt;/span&gt;-&lt;span class="n"&gt;uikit&lt;/span&gt;/&lt;span class="n"&gt;src&lt;/span&gt;/&lt;span class="n"&gt;iosMain&lt;/span&gt;/&lt;span class="n"&gt;objc&lt;/span&gt;/&lt;span class="n"&gt;CMPUIKitUtils&lt;/span&gt;/&lt;span class="n"&gt;CMPUIKitUtils&lt;/span&gt;.&lt;span class="n"&gt;xcodeproj&lt;/span&gt;/&lt;span class="n"&gt;project&lt;/span&gt;.&lt;span class="n"&gt;pbxproj&lt;/span&gt;
&lt;span class="n"&gt;SUPPORTED_PLATFORMS&lt;/span&gt; = &lt;span class="s2"&gt;"appletvos appletvsimulator iphoneos iphonesimulator"&lt;/span&gt;;
&lt;span class="n"&gt;TARGETED_DEVICE_FAMILY&lt;/span&gt; = &lt;span class="s2"&gt;"1,2,3"&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These settings are per build configuration. There are ten configurations in that file, across four targets plus the project itself. I set &lt;code&gt;SUPPORTED_PLATFORMS&lt;/code&gt; on the two project level ones so everything inherits the tvOS SDKs. I set &lt;code&gt;TARGETED_DEVICE_FAMILY = "1,2,3"&lt;/code&gt; on six configurations. Two still read &lt;code&gt;"1,2"&lt;/code&gt;: the Debug and Release pair for the &lt;code&gt;CMPUIKitSwiftUtils&lt;/code&gt; target.&lt;/p&gt;

&lt;p&gt;That target is on the tvOS build path. &lt;code&gt;configureSwiftUtils&lt;/code&gt; runs for every &lt;code&gt;configure()&lt;/code&gt; call, tvOS included, so both tvOS targets archive it with &lt;code&gt;xcodebuild -sdk appletvos&lt;/code&gt; or &lt;code&gt;appletvsimulator&lt;/code&gt;. It builds because &lt;code&gt;SUPPORTED_PLATFORMS&lt;/code&gt; is inherited from the project and does list the tvOS SDKs, and because the product is a static library archived with &lt;code&gt;MACH_O_TYPE=staticlib&lt;/code&gt;, which is not something you install on a device, so nothing ever checks the device family. It is still wrong and it is an open item on my list. If Apple tightens that check, or if the target stops being a static library, it breaks.&lt;/p&gt;

&lt;p&gt;Xcode's UI does not show when a setting is present on some configurations and not others, and pbxproj is not a format you read carefully by eye. I checked these by grepping for the setting name and comparing the number of hits against the number of configurations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources: decoding the SDK name
&lt;/h2&gt;

&lt;p&gt;The second repository is &lt;code&gt;compose-multiplatform&lt;/code&gt;, home of the Compose Gradle plugin. For tvOS it needed one thing: resources.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/IosResourcesTasks.kt&lt;/span&gt;
&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appletvos"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;archs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;arch&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"arm64"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"arm64e"&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;KonanTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TVOS_ARM64&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unknown tvOS device arch: '$arch'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same branch handles &lt;code&gt;appletvsimulator&lt;/code&gt;. The predicate that decides whether a target is one the plugin handles at all also had to widen: &lt;code&gt;isIosOrMacTarget&lt;/code&gt; now ends with &lt;code&gt;|| isTvosTarget()&lt;/code&gt;. This is the same pattern as the linker flags, where the condition was already there and only needed to know about one more Apple platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only arm64
&lt;/h2&gt;

&lt;p&gt;This fork has two tvOS targets: &lt;code&gt;tvosArm64&lt;/code&gt; for the device and &lt;code&gt;tvosSimulatorArm64&lt;/code&gt; for the simulator. There is no &lt;code&gt;tvosX64&lt;/code&gt;, even though Kotlin/Native does have a &lt;code&gt;TVOS_X64&lt;/code&gt; entry and it shows up in a grep, including in the resource decoder above, where the simulator branch maps &lt;code&gt;x86_64&lt;/code&gt; to it. That branch is copy paste from the iOS decode and is dead in practice, because the only machine that would produce an x86_64 Apple TV simulator is an Intel Mac.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TvosX64&lt;/code&gt; was in &lt;code&gt;ComposePlatforms&lt;/code&gt; and in the &lt;code&gt;TV_OS&lt;/code&gt; set long before I touched anything. Upstream took it out in January 2026 in a commit called "remove apple x64 targets", which dropped &lt;code&gt;MacosX64&lt;/code&gt;, &lt;code&gt;IosX64&lt;/code&gt;, &lt;code&gt;TvosX64&lt;/code&gt; and &lt;code&gt;WatchosX64&lt;/code&gt; together with the api dumps that referenced them. So I inherited a tree with no Apple x64 anywhere, and I did not re-add it, because it would mean one more klib to build, publish and rebase for a shrinking set of users. The only place the name still came up is in the plugin repository, where an integration test referenced &lt;code&gt;tvosX64&lt;/code&gt; and now points at &lt;code&gt;tvosSimulatorArm64&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;UIViewController&lt;/code&gt; on tvOS, &lt;code&gt;ComposeSceneMediator&lt;/code&gt; 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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Previous: &lt;a href="https://dev.to/sajidaledev/my-journey-making-compose-multiplatform-work-on-tvos-and-what-i-learned-1mo3"&gt;Part 1, My Journey Making Compose Multiplatform Work on tvOS&lt;/a&gt;. Next: Part 3, Rendering (coming soon).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apps.apple.com/pk/app/pehra/id6808605595" rel="noopener noreferrer"&gt;Pehra on the App Store&lt;/a&gt;, the published tvOS app built on this fork&lt;/li&gt;
&lt;li&gt;Gradle plugin and docs: &lt;a href="https://github.com/sajidalidev/compose-tvos" rel="noopener noreferrer"&gt;github.com/sajidalidev/compose-tvos&lt;/a&gt; and &lt;a href="https://sajidalidev.github.io/compose-tvos/" rel="noopener noreferrer"&gt;sajidalidev.github.io/compose-tvos&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Compose runtime fork: &lt;a href="https://github.com/sajidalidev/compose-multiplatform-core" rel="noopener noreferrer"&gt;github.com/sajidalidev/compose-multiplatform-core&lt;/a&gt; (branch &lt;code&gt;tvos-main&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Gradle plugin fork: &lt;a href="https://github.com/sajidalidev/compose-multiplatform" rel="noopener noreferrer"&gt;github.com/sajidalidev/compose-multiplatform&lt;/a&gt; (branch &lt;code&gt;tvos-main&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A full sample app, Google's JetStream ported to tvOS: &lt;a href="https://github.com/sajidalidev/jetstream-tvos" rel="noopener noreferrer"&gt;github.com/sajidalidev/jetstream-tvos&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;tvOS-enabled forks of Koin and Coil: &lt;a href="https://github.com/sajidalidev/koin" rel="noopener noreferrer"&gt;github.com/sajidalidev/koin&lt;/a&gt;, &lt;a href="https://github.com/sajidalidev/coil" rel="noopener noreferrer"&gt;github.com/sajidalidev/coil&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>appletv</category>
      <category>compose</category>
      <category>android</category>
      <category>kotlin</category>
    </item>
    <item>
      <title>My Journey Making Compose Multiplatform Work on tvOS, and What I Learned</title>
      <dc:creator>Sajid Ali</dc:creator>
      <pubDate>Sat, 05 Sep 2026 07:43:02 +0000</pubDate>
      <link>https://dev.to/sajidalidev/my-journey-making-compose-multiplatform-work-on-tvos-and-what-i-learned-1mo3</link>
      <guid>https://dev.to/sajidalidev/my-journey-making-compose-multiplatform-work-on-tvos-and-what-i-learned-1mo3</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 of a series. This post is the high-level overview. The next posts go into each module in detail.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I am an Android and Android TV developer with about ten years of experience. For the last few years I have been leading a streaming app at Devolic that has to run on Android mobile, Android TV, desktop, iOS and tvOS. This series is about how that requirement ended with me maintaining a fork of Compose Multiplatform with tvOS support, and what I learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we got here
&lt;/h2&gt;

&lt;p&gt;The app did not start out multiplatform. It went through the same three phases as a lot of Android codebases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android Views.&lt;/strong&gt; The first version was classic Views and XML, one codebase for phone and TV with a lot of &lt;code&gt;if (isTv)&lt;/code&gt; checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jetpack Compose.&lt;/strong&gt; When Compose became stable we rewrote the UI in it. The TV side got a lot better because focus handling in Compose is explicit, and &lt;code&gt;androidx.tv:tv-material&lt;/code&gt; gave us proper TV components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compose Multiplatform.&lt;/strong&gt; Then desktop, iOS and tvOS were added to the roadmap. We are a small team. Building and maintaining four or five separate UI layers was never realistic, and even with more people, keeping feature parity between them is where small teams lose the most time. Compose Multiplatform was the obvious choice: same UI code, same design system, same navigation on every platform.&lt;/p&gt;

&lt;p&gt;Except for one platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Kotlin Multiplatform with SwiftUI on the TV?
&lt;/h2&gt;

&lt;p&gt;The first plan for Apple TV was the usual one: share the business logic with Kotlin Multiplatform and write the tvOS UI natively in SwiftUI.&lt;/p&gt;

&lt;p&gt;We tried it, and SwiftUI's focus management was the deal breaker. On a TV, focus is the whole user interface. You need to decide exactly where focus goes on every D-pad press, restore it when the user comes back to a screen, keep it inside a dialog, move it into a row and remember which item was focused last time. Compose gives you &lt;code&gt;FocusRequester&lt;/code&gt;, &lt;code&gt;focusProperties&lt;/code&gt;, &lt;code&gt;focusRestorer&lt;/code&gt; and the ability to intercept key events anywhere in the tree. SwiftUI gives you a handful of modifiers and a focus engine that mostly does what it wants. We spent a lot of time fighting it and still could not match the behaviour we already had on Android TV.&lt;/p&gt;

&lt;p&gt;So the question became: how hard would it be to make Compose Multiplatform itself run on tvOS?&lt;/p&gt;

&lt;h2&gt;
  
  
  What tvOS actually is
&lt;/h2&gt;

&lt;p&gt;Once I started researching, the picture was more encouraging than I expected.&lt;/p&gt;

&lt;p&gt;tvOS is iOS with a different input model and some frameworks removed. Same kernel, same Objective-C runtime, same UIKit at the core, same Metal. The differences that matter for a UI toolkit are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No touch screen. Input comes from the Siri Remote as &lt;code&gt;UIPress&lt;/code&gt; events plus indirect touches from the trackpad.&lt;/li&gt;
&lt;li&gt;Focus is driven by UIKit's focus engine, and every view is expected to take part in it.&lt;/li&gt;
&lt;li&gt;A long list of iOS APIs simply do not exist: drag and drop, hover gestures, the text loupe, edit menus, screen edge pan gestures, window scene orientation APIs, and so on.&lt;/li&gt;
&lt;li&gt;A different SDK name (&lt;code&gt;appletvos&lt;/code&gt; / &lt;code&gt;appletvsimulator&lt;/code&gt;) and different Kotlin/Native targets (&lt;code&gt;tvosArm64&lt;/code&gt;, &lt;code&gt;tvosSimulatorArm64&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the real cost. In Kotlin Multiplatform, a target is a target. It does not matter how similar tvOS is to iOS: every library in the dependency graph has to publish a &lt;code&gt;tvosArm64&lt;/code&gt; variant or the build fails during dependency resolution. Compose Multiplatform is a deep stack of modules, so that meant going through the entire stack.&lt;/p&gt;

&lt;p&gt;The one thing that could have stopped the idea before it started was Skiko, the Skia binding that Compose Multiplatform uses for rendering. If Skiko had no tvOS build, I would have had to build Skia for tvOS myself. It turned out that Skiko already published tvOS targets. That was a major relief, and the moment the project went from "maybe" to "let's do it".&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding how to do it
&lt;/h2&gt;

&lt;p&gt;There were two repositories to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Gradle plugin&lt;/strong&gt; (&lt;code&gt;compose-multiplatform&lt;/code&gt; repository). This is where the Compose Gradle plugin decides which Kotlin targets it knows how to configure, mostly for resources. Kotlin Multiplatform already supported tvOS, so this was a matter of telling the plugin that a tvOS target is an Apple target that needs the same resource handling as iOS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;KotlinNativeTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isTvosSimulatorTarget&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;konanTarget&lt;/span&gt; &lt;span class="p"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;KonanTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TVOS_X64&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;konanTarget&lt;/span&gt; &lt;span class="p"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;KonanTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TVOS_SIMULATOR_ARM64&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;KotlinNativeTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isTvosDeviceTarget&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;konanTarget&lt;/span&gt; &lt;span class="p"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;KonanTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TVOS_ARM64&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;KotlinNativeTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isTvosTarget&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;isTvosSimulatorTarget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isTvosDeviceTarget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;KotlinNativeTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isIosOrMacTarget&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;isIosTarget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isMacTarget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isTvosTarget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The runtime&lt;/strong&gt; (&lt;code&gt;compose-multiplatform-core&lt;/code&gt; repository). This is the actual &lt;code&gt;androidx.compose.*&lt;/code&gt; code with JetBrains' iOS, desktop and web implementations. Every module with a UIKit-specific implementation needed a tvOS one. The build setup already had a &lt;code&gt;TV_OS&lt;/code&gt; platform enum that was not used anywhere. Enabling it was a one-line change in &lt;code&gt;ComposePlatforms.kt&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;SKIKO_SUPPORT&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EnumSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KotlinMultiplatform&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;JVM_BASED&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;IOS&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;TV_OS&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;MACOS_NATIVE&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;WEB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus registering the two targets in the shared target configuration with the same UIKit linker flags that iOS uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;uikitFlags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"-linker-option"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-framework"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-linker-option"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"UIKit"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;multiplatformExtension&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;iosArm64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;configureFreeCompilerArgs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;iosSimulatorArm64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;configureFreeCompilerArgs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;tvosArm64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;configureFreeCompilerArgs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;tvosSimulatorArm64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;configureFreeCompilerArgs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the targets enabled, the build immediately showed what was missing: dozens of &lt;code&gt;expect&lt;/code&gt; declarations without a tvOS &lt;code&gt;actual&lt;/code&gt;, and a handful of Objective-C files that did not compile against the tvOS SDK. That is where the real work started, and I needed an order to do it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going through the modules in dependency order
&lt;/h2&gt;

&lt;p&gt;The approach I settled on was simple. Start with the module that does not depend on any other Compose module, make it build for tvOS, then move to the modules that only depend on modules that are already done. Never touch a module before its dependencies compile.&lt;/p&gt;

&lt;p&gt;The order ended up like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compose:ui:ui-uikit          &amp;lt;- Objective-C / cinterop layer, no Compose dependencies. Start here.
   └─ compose:ui:ui-util
       └─ compose:ui:ui-text
           └─ compose:ui:ui       &amp;lt;- ComposeUIViewController, rendering, input, focus, density
               ├─ compose:ui:ui-test
               └─ compose:foundation:foundation
                   ├─ compose:material3 (+ adaptive, ripple)
                   ├─ navigation-compose / navigation3-ui / window-core
                   └─ tv:tv-material   &amp;lt;- ported from the Android-only androidx.tv library
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One decision made this much easier. Instead of a separate &lt;code&gt;tvosMain&lt;/code&gt; copy of everything, I added a shared &lt;code&gt;uiKitMain&lt;/code&gt; source set that both &lt;code&gt;iosMain&lt;/code&gt; and &lt;code&gt;tvosMain&lt;/code&gt; depend on. Anything that is plain UIKit code lives there once. Only the parts that are actually different get a tvOS-specific file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;uiKitMain&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dependsOn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nativeMain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dependencies&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;implementation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;":compose:ui:ui-uikit"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;iosMain&lt;/span&gt;  &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;dependsOn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiKitMain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;tvosMain&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;dependsOn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiKitMain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The first module: ui-uikit
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ui-uikit&lt;/code&gt; is the thin Objective-C and Swift layer that Compose's Kotlin code calls through cinterop. It is an Xcode project (&lt;code&gt;CMPUIKitUtils&lt;/code&gt;) that Gradle builds with &lt;code&gt;xcodebuild&lt;/code&gt; and exposes to Kotlin through a generated &lt;code&gt;.def&lt;/code&gt; file. Because it has no Kotlin dependencies on the rest of Compose, it was the natural first step. It was also the smallest step in the whole port: around 110 lines across 20 files, in a single commit.&lt;/p&gt;

&lt;p&gt;Three kinds of changes were needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Build the Xcode project for the tvOS SDKs.&lt;/strong&gt; The Gradle code that runs &lt;code&gt;xcodebuild&lt;/code&gt; assumed iPhone SDK names, so it had to learn about &lt;code&gt;appletvos&lt;/code&gt; and &lt;code&gt;appletvsimulator&lt;/code&gt; and the matching build destinations. The Xcode project itself needed the tvOS platforms and the Apple TV device family added. I will cover the whole build setup, including a cinterop quirk around linker options, in the next post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Exclude UIKit APIs that do not exist on tvOS.&lt;/strong&gt; This was most of the diff. Every file that used an iOS-only class got a &lt;code&gt;TARGET_OS_TV&lt;/code&gt; check. For example, the accessibility element uses &lt;code&gt;UIFocusHaloEffect&lt;/code&gt;, which tvOS does not have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if !TARGET_OS_TV
&lt;/span&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UIFocusEffect&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;focusEffect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UIFocusHaloEffect&lt;/span&gt; &lt;span class="nf"&gt;effectWithRect&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;focusEffectRect&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="cp"&gt;#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whole classes were excluded the same way: &lt;code&gt;CMPDragInteractionProxy&lt;/code&gt; and &lt;code&gt;CMPDropInteractionProxy&lt;/code&gt; (no drag and drop on tvOS), &lt;code&gt;CMPHoverGestureRecognizer&lt;/code&gt; (no pointer hover), &lt;code&gt;CMPScreenEdgePanGestureRecognizer&lt;/code&gt;, &lt;code&gt;CMPTextLoupeSession&lt;/code&gt; and the &lt;code&gt;UIWindowScene&lt;/code&gt; orientation extensions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Add empty implementations for classes that cinterop still references.&lt;/strong&gt; Excluding a header is not always enough. &lt;code&gt;CMPEditMenuView&lt;/code&gt; is declared in a header that the shared cinterop definition still includes, so the Kotlin side references the class and the linker needs the symbol. On tvOS it gets an empty implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if TARGET_OS_TV
&lt;/span&gt;&lt;span class="c1"&gt;// tvOS stub: CMPEditMenuView is iOS-only. This empty implementation satisfies&lt;/span&gt;
&lt;span class="c1"&gt;// the linker when the interface is included via the shared cinterop headers.&lt;/span&gt;
&lt;span class="k"&gt;@implementation&lt;/span&gt; &lt;span class="nc"&gt;CMPEditMenuView&lt;/span&gt;
&lt;span class="k"&gt;@end&lt;/span&gt;
&lt;span class="cp"&gt;#else // !TARGET_OS_TV
&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt; &lt;span class="n"&gt;implementation&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="cp"&gt;#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once &lt;code&gt;ui-uikit&lt;/code&gt; produced a framework for both tvOS targets, I could move on to the rest of the modules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which module needed the most effort
&lt;/h2&gt;

&lt;p&gt;By a wide margin, &lt;code&gt;compose:ui:ui&lt;/code&gt;. Roughly 4,400 lines changed across 25 commits, compared to about 110 lines in &lt;code&gt;ui-uikit&lt;/code&gt; and under 300 in &lt;code&gt;foundation&lt;/code&gt;. It is the module where tvOS stops being "iOS with some APIs missing" and becomes a genuinely different platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rendering and hosting.&lt;/strong&gt; &lt;code&gt;ComposeSceneMediator&lt;/code&gt;, &lt;code&gt;ComposeContainer&lt;/code&gt;, the hosting view and the view controller all have tvOS versions. These files own the UIKit view hierarchy, rendering through Skiko, and the interop with native views. They are also the files JetBrains refactors most often, so keeping them in sync with upstream is the main ongoing maintenance cost of this fork.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Siri Remote input.&lt;/strong&gt; &lt;code&gt;UIPress&lt;/code&gt; and &lt;code&gt;UIPressesEvent&lt;/code&gt; have to be converted into Compose &lt;code&gt;KeyEvent&lt;/code&gt;s. D-pad navigation, swipe-to-focus on the trackpad, key repeat, treating the Menu button as &lt;code&gt;Key.Back&lt;/code&gt;, and telling a trackpad click apart from a swipe (which needed hardware timestamps, because the trackpad is itself a button).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Focus.&lt;/strong&gt; UIKit's focus engine and Compose's focus system have to agree on which one owns focus, which happens through &lt;code&gt;didUpdateFocusInContext&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screen density.&lt;/strong&gt; UIKit reports an Apple TV at the same scale as a phone, which gives a 1080p screen a 1920x1080 dp canvas. That is far too dense for a TV UI viewed from the couch. Android TV treats 1080p as density 2.0, a 960x540 dp canvas, and squaring the UIKit scale reproduces exactly that. The original scale is kept for insets and accessibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text input.&lt;/strong&gt; tvOS has a full-screen keyboard instead of the inline one on iOS, so it has its own text input service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No-op implementations.&lt;/strong&gt; Drag and drop, haptics and clipboard get empty implementations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Foundation mostly needed the move to the shared &lt;code&gt;uiKitMain&lt;/code&gt; source set plus tvOS implementations for things like text selection handles and the magnifier that do not exist on a TV. Material3 was almost entirely build configuration. And &lt;code&gt;tv-material&lt;/code&gt;, the component library that makes a TV app look like a TV app, had to be moved from an Android-only AndroidX module into &lt;code&gt;commonMain&lt;/code&gt; with &lt;code&gt;expect&lt;/code&gt;/&lt;code&gt;actual&lt;/code&gt; pairs for the Android and tvOS specifics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check the critical dependency first.&lt;/strong&gt; Skiko having tvOS support was the deciding factor. Confirm that kind of thing before writing any code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port modules in dependency order.&lt;/strong&gt; Every failed build then points at exactly one module, and you never chase errors caused by a dependency you have not ported yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share as much as possible between iOS and tvOS.&lt;/strong&gt; The &lt;code&gt;uiKitMain&lt;/code&gt; source set kept the tvOS-specific code small enough for one person to maintain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hard part is not the missing APIs, it is input.&lt;/strong&gt; Excluding &lt;code&gt;UIHoverGestureRecognizer&lt;/code&gt; takes a minute. Making the Siri Remote feel right takes weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect constant upstream changes.&lt;/strong&gt; A fork of a fast-moving toolkit needs regular rebasing. The rendering and hosting files had to be adapted to upstream refactors many times.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything is published and usable today as a community project. It is not official JetBrains work.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gradle plugin and docs: &lt;a href="https://github.com/sajidalidev/compose-tvos" rel="noopener noreferrer"&gt;github.com/sajidalidev/compose-tvos&lt;/a&gt; and &lt;a href="https://sajidalidev.github.io/compose-tvos/" rel="noopener noreferrer"&gt;sajidalidev.github.io/compose-tvos&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Compose runtime fork: &lt;a href="https://github.com/sajidalidev/compose-multiplatform-core" rel="noopener noreferrer"&gt;github.com/sajidalidev/compose-multiplatform-core&lt;/a&gt; (branch &lt;code&gt;tvos-main&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Gradle plugin fork: &lt;a href="https://github.com/sajidalidev/compose-multiplatform" rel="noopener noreferrer"&gt;github.com/sajidalidev/compose-multiplatform&lt;/a&gt; (branch &lt;code&gt;tvos-main&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A full sample app, Google's JetStream ported to tvOS: &lt;a href="https://github.com/sajidalidev/jetstream-tvos" rel="noopener noreferrer"&gt;github.com/sajidalidev/jetstream-tvos&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;tvOS-enabled forks of Koin and Coil: &lt;a href="https://github.com/sajidalidev/koin" rel="noopener noreferrer"&gt;github.com/sajidalidev/koin&lt;/a&gt;, &lt;a href="https://github.com/sajidalidev/coil" rel="noopener noreferrer"&gt;github.com/sajidalidev/coil&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The upstream feature request, if you want to add your vote: &lt;a href="https://youtrack.jetbrains.com/issue/CMP-5686" rel="noopener noreferrer"&gt;CMP-5686&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adding it to an existing Compose Multiplatform project is one plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;plugins&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dev.sajidali.compose-tvos"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="s"&gt;"1.4.2"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;kotlin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;tvosArm64&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;tvosSimulatorArm64&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep your normal &lt;code&gt;org.jetbrains.compose.*&lt;/code&gt;, &lt;code&gt;androidx.tv:tv-material&lt;/code&gt;, Koin and Coil dependencies. The plugin redirects them to tvOS-capable builds of the exact same versions. Kotlin 2.3.20 or newer is required.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is next in this series
&lt;/h2&gt;

&lt;p&gt;This post stayed deliberately high level. Over the coming weeks I will go through each part in detail:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Overview.&lt;/strong&gt; This post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build setup.&lt;/strong&gt; Kotlin targets, the shared &lt;code&gt;uiKitMain&lt;/code&gt; source set, the fork's separate build files, the ui-uikit Xcode and cinterop build, and tvOS resources in the Gradle plugin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rendering.&lt;/strong&gt; How Compose draws inside a UIViewController on tvOS: &lt;code&gt;ComposeSceneMediator&lt;/code&gt;, the hosting view, layers, Skiko and Metal, frame scheduling, and why these files change the most on every upstream rebase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Siri Remote input.&lt;/strong&gt; Converting &lt;code&gt;UIPress&lt;/code&gt; to &lt;code&gt;KeyEvent&lt;/code&gt;, D-pad navigation, connecting UIKit focus with Compose focus, and the Menu button as Back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Siri Remote trackpad.&lt;/strong&gt; Swipe-to-focus, telling a click from a swipe with hardware timestamps, phantom swipes, key repeat and long press.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screen density and text input.&lt;/strong&gt; Why the density is the square of the screen scale, and building a text input service around the full-screen tvOS keyboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Porting tv-material.&lt;/strong&gt; Surface, Carousel, SurfaceGlow, accessibility, and a null Skia pointer that took a while to find.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Gradle plugin and third-party libraries.&lt;/strong&gt; Why one plugin instead of new Maven coordinates, Koin and Coil, libraries that publish no tvOS variant, and the Kotlin version requirement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintaining the fork.&lt;/strong&gt; Rebasing onto upstream, the release process, compile-verified versus test-verified, and what I would do differently.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you have a Compose app and a TV target on your roadmap, I would love to hear what breaks when you try it.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>tvos</category>
      <category>compose</category>
    </item>
  </channel>
</rss>
