
Runtimes that read .rc wire documents and render UI across hosts: modular protocol and runtime plus a Compose-based renderer, Wasm bundle, XCFramework distribution and reference players.
Remote Compose players — the runtimes that read a .rc document and draw it.
A .rc document is Remote Compose's
wire format: a compact, versioned operation stream that describes a piece of UI without shipping
code. This repository holds the players that interpret one, on every surface this stack targets, plus
the vendored AndroidX player they are all measured against.
Six lanes are listed because implementation, packaging, hosting, and comparison answer different questions. The supported renderer remains CMP; UIKit is an explicitly experimental second renderer.
| Player | Target | Language / runtime | Supported? | Why it exists |
|---|---|---|---|---|
CMP player (rc-player/compose) |
JVM · Android · iOS (iosArm64, iosSimulatorArm64) · macOS (macosArm64) · wasmJs
|
Kotlin Multiplatform + Compose Multiplatform | Yes — the one supported API here | The player written here. One implementation that draws a document natively on every surface this stack targets, with a platform-neutral wire model underneath it. This is what a consumer should depend on. |
Wasm host (rc-player/wasm) |
Browser | The CMP player compiled to WebAssembly | Yes, as an embed contract | Makes the CMP player renderable in a page with no server: an iframe driven by query parameters and window.rcPlayerLoad / window.rcPlayerLoadBytes, or by postMessage from another origin. It is the CMP player, not a second implementation — same pixels, different host. |
Apple XCFramework (Package.swift) |
iOS + Apple-silicon macOS, from Swift | The CMP player's native Apple targets, packaged for SwiftPM | Yes | Same code again, reachable from a Swift app that does not build Kotlin. Distribution, not implementation. |
Native UIKit POC (Sources/RcNativePlayerUIKit) |
iOS | Pure Swift + UIKit/Core Graphics/Core Text | Experimental | A second renderer beside CMP. It explores an idiomatic native component tree and reports unsupported operations explicitly; it is not a replacement or compatibility claim. |
macOS player app (samples/macos-player) |
Apple-silicon macOS | SwiftUI host + CMP or experimental AppKit-native rendering | Release utility | Opens local .rc files and persists a renderer preference, so both Apple desktop paths can be exercised from one downloadable app. |
Vendored AndroidX player (third_party/rc-embedded-player) |
Android (Robolectric) | Kotlin + Compose, vendored from androidx-main | No — testing only | The comparison lane. AndroidX's own embedded player, pinned to one commit and locally patched, so a parity number is attributable to a known player rather than to whichever alpha resolved that day. |
Vendored TypeScript player (third_party/remote-compose-player) |
Browser · Node · VS Code webview | TypeScript → Canvas2D, WebGL for shader ops | No — vendored, upstream elsewhere | A client-side lane that needs no Kotlin at all, so a viewer can render a captured .rc without a server-side daemon. Upstream is yschimke/remotecompose-experiments; changes are filed there. |
The shape of the whole thing: one supported implementation, one experimental renderer, two CMP hosts, and two references. The CMP player is the product; the Wasm bundle and XCFramework are it in different wrappers. The native UIKit POC sits beside it, while the vendored AndroidX player and the TypeScript player remain comparison lanes. There is no separate JVM reference player: the vendored player's JVM cut was removed on 2026-09-25, and where a JVM player is needed, the CMP player is it.
Every tool that lets you choose a player — compose-preview serve's ?rcPlayer=, the daemon's
RemoteComposeOverride.playerId, a capture's capturePlayer, the rc-compare columns — names it
with one of these ids. The id names the implementation that draws, and cmp- means the CMP
player above and nothing else: cmp-android, cmp-jvm and cmp-wasm are one codebase on three
hosts.
| id | Implementation | Where it runs | Also accepted |
|---|---|---|---|
cmp-android |
CMP player (rc-player-compose) on Android |
the preview daemon, under Robolectric | — |
cmp-jvm |
CMP player on the desktop JVM, over Skiko |
compose-preview serve's rc-render-jvm subprocess |
rcplayer-jvm |
cmp-wasm |
CMP player compiled to Wasm (rc-player/wasm) |
the browser | rcplayer-wasm |
androidx-embedded |
Vendored AndroidX embedded player (third_party/rc-embedded-player) |
the preview daemon, under Robolectric | embedded |
androidx-view |
AndroidX remote-player-view RemoteComposePlayer (an Android View) |
the preview daemon, under Robolectric |
java, view
|
camaelon-js |
Vendored TypeScript player (third_party/remote-compose-player) |
the browser | js |
androidx-view is not in this repository: it is AndroidX's own published player, drawn by the
daemon for comparison. The two AndroidX players are the only ones a capture bakes through, so a
capture records androidx-embedded or androidx-view.
cmp-android used to mean the AndroidX embedded player, and some published links and capture
sidecars still say it with that meaning. As a request it now selects the CMP player. In a
capturePlayer field the old meaning is still read as androidx-embedded, because the CMP player
never captures, so that field cannot hold the new one. The same goes for the bare cmp, which is
no longer accepted anywhere: it cannot say which of the three hosts it means.
The supported API, published to Maven Central. Four modules, bottom-up — each is the base of the next, and the dependency arrow is also the dependency-weight arrow:
| Module | Artifact | What it is |
|---|---|---|
rc-player/trace |
rc-player-trace |
The tracing seam. An expect/actual facade so the stack above can open spans on any target. |
rc-player/protocol |
rc-player-protocol |
The .rc wire codec and operation model: reader, writer, immutable operation IR, and the generated AndroidX operation inventory. No Compose dependency at all — usable from a build tool or a server. |
rc-player/runtime |
rc-player-runtime |
Document semantics with no drawing: expression evaluation, animation timelines, layout tree, theming, named values. Still no Compose. |
rc-player/compose |
rc-player-compose |
The Compose Multiplatform renderer. RcComposePlayer draws a document; this is the module most consumers want. |
Beside the stack, and depending on protocol and runtime rather than the other way round:
| Module | Artifact | What it is |
|---|---|---|
rc-player/edit |
rc-player-edit |
Rewrites over a decoded document, for code that changes a .rc file rather than plays it: embed URL bitmaps inline, hardcode host-bound named values, collapse state layouts whose index can no longer change to their one live branch — when the hidden branches hold only layout, modifiers, drawing and touch handlers, so nothing outside can depend on them — fold and strip what a document no longer needs, and check or adapt a document for the player it targets. No Compose. |
val document = RcDocumentCodec.decode(bytes)
val baked =
document
.embedBitmapUrls { url -> httpClient.get(url).readBytes() } // may suspend
.hardcodeNamedValues(mapOf("tab" to RcHardcodedValue.Integer(2)))
.hardcodeUnboundNamedValues(bound = setOf("progress"))
.collapseConstantStateLayouts()
.optimize()
File("baked.rc").writeBytes(RcDocumentCodec.encode(baked))optimize() makes a document smaller and cheaper to play without changing what it plays: it
strips remarks and debug messages, folds every value producer whose value can never change into a
constant (integer and float expressions, colour expressions and the channels read from a colour,
merged, formatted and looked-up text — each evaluated by the player itself), drops repeated
identical definitions, and removes definitions nothing reads. sizeReport() shows where the bytes
are. Over the catalog corpus that is 7% (1.69 MB to 1.59 MB), every document checked to give every
remaining value exactly what the original did.
It pays most on a widget whose host values are baked in, as above: hardcoding turns the named
colours and values a document reads into constants, and optimize() after it turns what is
derived from them into constants too. Over the corpus, hardcoding every named value and
optimising takes it to 1.47 MB (13% smaller), folds nine in ten colour channel reads and two
thirds of the colour expressions, and cuts the time the player spends computing values each frame by about a
third.
It also checks a document against the player it is meant for. RcTargetProfiles names the
profiles AndroidX's creation API writes for — WIDGETS_V6 (launcher widgets on Android 16),
WIDGETS_V7, ANDROIDX, ANDROIDX_EXPERIMENTAL, WEAR_WIDGETS — with operation sets transcribed
from AndroidX's own tables; RcTargetProfile.of(apiLevel, profiles) builds any other.
val wear = RcTargetProfiles.WEAR_WIDGETS
val document = wear.decode(bytes) // resolves `Skip` guards as the Wear player does
document.validateFor(wear) // every operation, field and header the player would reject
val adapted = document.adaptTo(wear) // inline macros, fold constants, re-declare the header
if (!adapted.isPlayable) println(adapted.violations)A target without CoreText — Android 16 launcher widgets, Wear widgets — gets the older
TextLayout wherever it can say the same thing. Material typography also sets letter spacing,
line height and font axes, which TextLayout has no field for:
adaptTo(target, approximateText = true) drops those and lists each drop in
RcProfileAdaptation.approximations; underline, strikethrough and autosize still block. To serve
older devices a different design instead, keep a separate template per profile and check each with
validateFor.
Neither API level 6 nor Wear has ColorTheme, the light/dark pair of system colours.
adaptTo(target, setOf(RcApproximation.THEME_COLORS)) replaces each one with its recorded
fallbacks (colorThemesAsFallbacks()): in theme sections, the way AndroidX's level 6 widget writer
writes a themed colour, or as the dark fallback on Wear. The device's palette is lost. Evidence is
under
renders/rc-edit-profile-fallbacks/.
There are no Apple x86_64 slices anywhere in the stack — Compose Multiplatform 1.11 stopped publishing them — so Intel iOS simulators and Intel Macs cannot build against it.
All five run explicitApi() and an ABI dump gate: checkKotlinAbi diffs the real public surface
against the committed dumps in each module's api/, so a change to the published API shows up as a
diff in review. Regenerate with ./gradlew updateKotlinAbi.
Published twice, because its two consumers resolve differently and neither channel substitutes for the other:
@yschimke/remote-compose-player-cmp — how a web page consumes it;rc-player-wasm-dist (a zip, dist classifier) — how a Gradle build stages it as a
static sidecar.Both are cut from wasmPlayerDist, which enforces a size budget — a ratchet, not a target, so an
unintended jump fails the build before an irreversible publish.
A page can include the player with one script tag: rc-cmp-player.js, shipped in the
distribution beside index.html, owns the iframe and exposes RcCmp.createPlayer(...) and an
<rc-cmp-player> element, the same shape as the TypeScript player's RC.createPlayer and
<rc-player> (embed contract). The drag-and-drop comparison page
(samples/web-compare) includes both libraries and draws a dropped .rc
file in each; stage and serve it with:
build-brief ./gradlew :rc-player-wasm:rcCompareSite
python3 -m http.server -d rc-player/wasm/build/rcCompareSite 8000 # open http://localhost:8000
The distribution carries its own font faces (rc-player/wasm/dist-assets/) because the lane is
manifest-only and never fetches: a family the bundle does not carry fails the availability check
outright.
rc-player/compose's iOS and Apple-silicon macOS targets, assembled into
RcComposePlayer.xcframework and distributed through Swift Package Manager. Consume it by a
bare X.Y.Z tag — the release job writes the
real url and checksum into Package.swift and publishes that commit as the bare tag once the
XCFramework is uploaded. The copy on main is a permanent placeholder: a manifest cannot state the
checksum of an asset that does not exist yet, so main and the v-prefixed tag both fail the
checksum check by design. Editing the two values by hand is never right. Usage is documented, and
type-checked in CI, in docs/design/RC_PLAYER_SWIFT.md.
The manifest is swift-tools-version: 6.0 and builds every target in the Swift 6 language mode
(complete strict concurrency), so it needs a Swift 6 toolchain — Xcode 16 or newer; the deployment
floor stays iOS 13, macOS 12 and visionOS 1.
Application targets normally select the RcComposePlayerSwiftUI product for direct Data, typed
events/errors, and SwiftUI/UIKit entry points; the RcComposePlayer product remains the raw binary
interop surface.
The experimental RcNativePlayerUIKit product is published by the same bare version tag. Each
GitHub Release also carries RcNativePlayerUIKit.swiftpackage.zip and its SHA-256 file: a
self-contained, pure-Swift local package containing the native sources with no binary target. It
gives evaluators a small pinned, offline-resolvable artifact without changing the supported CMP
product.
The native player's tests are two SwiftPM test targets written with swift-testing:
RcNativePlayerCoreTests for the pure-Swift document core and RcNativePlayerUIKitTests for the
platform layer. Run them on the Mac with RC_COMPOSE_PLAYER_NATIVE_ONLY=1 swift test — native-only
mode keeps the Kotlin binary target out of the package graph — and on an iOS simulator with
scripts/check-native-swift-ios-tests.sh. On Linux the manifest declares only the core and its
tests, so swift test --filter RcNativePlayerCoreTests runs there too.
samples/apple-player is the full application check: a SwiftUI document
player linked against the current release XCFramework build, built in CI for the arm64 iOS
Simulator, and
also runnable as a Designed for iPad desktop app on Apple-silicon Macs. It exercises application
integration that the generated-header sample cannot, including bundled and imported .rc files,
the UIKit controller lifecycle, required plist settings, and the framework's architecture matrix.
None of these is a supported API. Provenance, the pinned upstream commit and the full patch log for
the AndroidX ones: third_party/rc-embedded-player/PROVENANCE.md;
for the TypeScript one, third_party/remote-compose-player/PROVENANCE.md.
The AndroidX player is vendored rather than resolved from the alpha coordinates on purpose, and it
is worth being clear that this is not stubbornness about a fork. Upstream's
remote-player-compose snapshot does now ship the embedded player. The problem is that it ships it
into the same package these sources used to occupy, so which bytes ran was decided by classpath
ordering — and on 2026-08-22 upstream reshaped the entry point and every live render died with a
NoSuchMethodError. These sources moved to ee.schimke.composeai.rcembedded.player, a package
nobody else publishes into, so both copies can sit on one classpath and a comparison number is
attributable by construction rather than by luck.
The local deltas over upstream are tracked as issues on this repository and are meant to shrink: two of the original five were dropped when alpha17 restored them upstream.
scripts/rc-lane-ab/ renders the focused Android View/vendored-embedded A/B, while
scripts/rc-operation-conformance/render-lanes.sh sends one manifest and the same .rc bytes
through AndroidX View, released and/or snapshot AndroidX embedded, vendored Android embedded, and
CMP JVM, then validates every result and writes all pairwise pixel
scores to comparison.json. On macOS, --native-uikit adds the packaged simulator player to that
same manifest contract. scripts/check-native-uikit-comparison.sh is the focused five-document
CMP/UIKit regression used by CI. scripts/rc-text-metrics/ does the same for the focused
text-metrics set. Committed visual evidence is under renders/.
rc-player/compat-tests generates .rc fixtures with the real AndroidX writer, so the CMP reader is
tested against documents it did not produce. rc-player/profile and rc-player/metrics are the
profiling and text-metric harnesses. None of the three publishes anything.
For an Apple-only, simulator-oriented CMP/UIKit comparison, see
docs/design/APPLE_PLAYER_BENCHMARKS.md.
rc-conformance scores a player against AndroidX's own conformance corpus — 252 golds over 18
subsystems, each carrying its own compiled document — and writes conformance-results.json plus a
scorecard. It is how "how conformant is this player?" gets a number attached to it, against one
published reference rather than against the other lanes here. The corpus is upstream-unmerged and
therefore lives on the long-lived vendor/androidx-rc-conformance branch rather than on main; the
module resolves it as configuration, never by path. The design, the current CMP score and the work
list are in docs/design/RC_CONFORMANCE.md.
rc-player/demos is the host half of a custom component, which is the one part of the stack the
published API cannot show on its own: the player draws a Custom component only if the host
registers a renderer for its config name. Two are demonstrated — SupportSpannableString, which the
player ships (RcSpannableString) because it is AndroidX's contract, and an editable text field,
whose keystrokes go back into the document through a TEXT_RETURN channel. ./gradlew :rc-player-demos:run opens both in a window, the @Preview functions render in the IDE, and
RcDemoRenderTest rasterizes them headless (renders/rc-custom-components/). It publishes nothing
either.
dependencies {
implementation("ee.schimke.composeai:rc-player-compose:<version>")
}rc-player-compose pulls the rest of the stack transitively. Take rc-player-protocol on its own
when you only need to read or write the wire format.
URL and file bitmap payloads load asynchronously through Coil 3 by default, using the app's
SingletonImageLoader and its caches. The player includes Ktor networking with JVM/Android,
Apple and browser engines. Inline bitmap decoding stays in the player. Pending requests cancel
when the document or loader changes or the player leaves composition.
Override image loading for one player, or provide LocalRcImageLoader around several players:
val images = RcImageLoader { bitmap -> hostImages.load(bitmap.data.decodeToString()) }
RcComposePlayer(document, imageLoader = images)
CompositionLocalProvider(LocalRcImageLoader provides images) {
RcComposePlayer(document)
}An override returns a Compose ImageBitmap, or null for an unavailable image. It owns its access
policy and recoverable errors; it must not block the composition dispatcher. Use
RcImageLoader.Empty to disable external loading. To keep Coil while configuring authentication,
fetchers or caching, configure Coil's singleton before the first player is composed.
rememberCoilRcImageLoader() also returns the default adapter explicitly. Support reports remain
conservative until image ids are supplied through availableImageIds.
Compose dependency versions are consumer requirements, including implementation dependencies
published at runtime scope. The experimental third-party-rc-embedded-player now requests AndroidX
Compose 1.12.1, intentionally matching the CMP player's resolved Android Compose UI floor.
Renovate permits stable 1.12.x patches for these core coordinates; moving to a newer minor line
requires a coordinated compatibility review. Before this update its 1.10.0 direct pins already resolved Compose UI to
1.11.4 through Remote Compose 1.0.0-alpha19; the direct pins were not a promise of 1.10 runtime
compatibility. Compose UI 1.12.1's AAR metadata requires compileSdk 37 and AGP 9.1.0 (other
transitive artifacts can impose additional requirements).
An application declaring an older Compose version will normally resolve the higher transitive version. Strict older constraints can instead fail resolution; forcing or excluding dependencies to retain an older runtime is unverified and can cause linkage failures. This update is therefore unsuitable for clients that must remain on Compose 1.11 or earlier. See Gradle's conflict-resolution rules.
The supported rc-player-compose artifact uses the separate Compose Multiplatform dependency line
and androidx-compose-for-cmp Android pin; changing the vendored player's androidx-compose pin
does not change that artifact's requirements. It already requests AndroidX Google Fonts 1.12.1,
so it likewise does not promise support for an older forced Android Compose runtime. The protocol,
runtime and trace modules do not gain Compose dependencies from this update.
The common Compose API also supports host-rendered custom components. A document names a component
through LAYOUT_CUSTOM; the host registers Compose content under that name. Because the content is
inserted into the player's existing Compose tree, it can be a native control, a named slot, or
another RcComposePlayer. This is the same API on Android, JVM, iOS and Wasm—the browser bundle is
only one possible host.
The implementation and cross-player conformance roadmap for completing the operation set on iOS
and macOS is in
docs/design/RC_CMP_APPLE_OPERATION_PLAN.md.
lateinit var components: RcCustomComponentRegistry
components =
RcCustomComponentRegistry(
"slot:hero" to { _, modifier -> Hero(modifier) },
"rc:document" to { component, modifier ->
childDocuments[component.text(DOCUMENT_KEY)]?.let { child ->
RcComposePlayer(child, modifier, theme = theme, customComponents = components)
}
},
)
RcComposePlayer(compositeDocument, customComponents = components)Custom properties can contain literals, live float references, text references and declared
float/text return channels. See
docs/design/RC_COMPOSITION.md for the composite-document, slot and
state-ownership model.
Custom component renderers can display document children using component.Child(index, modifier)
or component.Children(). component.childCount includes all direct slots;
component.customChild(index) exposes a custom child's properties even when that child's config
has no registered renderer. See the upstream adaptation review.
Swift Package Manager, by the bare version tag — not main, and not the v-prefixed release
tag, neither of which carries a resolvable checksum:
.package(url: "https://github.com/yschimke/rc-players.git", from: "<version>")./gradlew build # everything
./gradlew allTests # the test suites
./gradlew ktfmtFormatAll # format before committing — CI gates on ktfmtCheckAllbuild-brief (static-var/build-brief,
MIT, a single Go binary with no runtime dependencies) sits in front of Gradle, writes every line
Gradle emits to a log file, and prints only what changes the next step: status, failed tasks, failed
tests, warnings, build scan URLs, and generated artifact paths. Gradle's exit code passes through
unchanged, so it is safe anywhere a bare ./gradlew was used.
brew install static-var/tap/build-brief # or: curl -fsSL https://bb.staticvar.dev/install.sh | bash
build-brief doctor # read-only; never runs GradleThen wrap the commands above, for example build-brief ./gradlew build or build-brief ./gradlew allTests. The managed block at the end of AGENTS.md carries the per-command rules and
can be regenerated with build-brief --install.
On a shared developer host, agents use scripts/agent-gradle.sh instead of invoking that wrapper
directly. Use its normal profile for focused work and scripts/agent-gradle.sh --exclusive allTests
for broad multiplatform, Wasm, or Apple task graphs. The exclusive profile uses the same machine lock
as the other Compose Preview repositories; interactive commands and hosted CI bypass it. Pass the
Apple link's required --max-workers=1 normally—the launcher preserves narrower explicit limits.
Worth knowing here specifically:
tasks, projects, dependencies, and dependencyInsight keep
their report bodies.--ci to local commands. It is opt-in and is not implied by the environment; this
repository's workflows continue to invoke Gradle directly..build-brief.json is available for project-specific regex matches but is intentionally absent
until this repository has a result line worth surfacing.An Android SDK is needed for third_party/rc-embedded-player and for the published rc-player/*
modules' Android target (ANDROID_HOME, or sdk.dir in local.properties). The Apple targets only build on macOS; on Linux the Kotlin Gradle plugin disables
them with a warning rather than failing.
-Pcomposeai.remoteCompose=snapshot swaps the pinned AndroidX Remote Compose alphas for the
androidx-main post-submit build pinned in settings.gradle.kts. Use it to exercise an API that has
landed upstream but has not been released; the default release line is what CI and published
consumers see.
Two lanes in .github/workflows/ci.yml, split by what the host can do
rather than by what the change touched:
Linux (ubuntu-latest) |
macOS (macos-15) |
|
|---|---|---|
JVM suites for the four published modules, compat-tests, metrics, profile
|
✅ | ✅ (via allTests) |
Android host tests for all four published modules (compose under Robolectric, including a remote-m3 catalog sample) |
✅ | |
Vendored Android player, Robolectric (testDebugUnitTest) |
✅ | |
| Vendored TypeScript player | ✅ | |
macosArm64Test, iosSimulatorArm64Test, wasmJsBrowserTest
|
✅ | |
iosArm64 — device |
compiled only; a hosted runner has no device to run it on | |
| The shipped Wasm bundle, in a real browser | ✅ scripts/wasm-smoke
|
|
ABI gate (checkKotlinAbi) |
✅ — the dumps cover the Apple klibs, which only build here | |
Native Swift player tests (RC_COMPOSE_PLAYER_NATIVE_ONLY=1 swift test, and the same target on an iOS simulator) |
✅, path-gated with the row below | |
| XCFramework link, Swift type check, Xcode packaging | ✅, and only when the change can affect them |
Both lanes run on every pull request. Only the last two rows are path-gated (the apple-changes
job): the release link is what makes the macOS lane expensive, and a change that cannot reach the
Apple artifacts should not pay for it. The native Swift tests share that gate because nothing
outside its paths — Sources, Tests/RcNativePlayerCoreTests, Tests/RcNativePlayerUIKitTests, the manifests — can change them. Test execution is never gated — a lane that can only run here is
worth its minutes on every change, and the previous arrangement, where the path filter skipped the
whole job, meant a change confined to rc-player/wasm or third_party/ merged without a single
Kotlin/Native or wasm test running.
Each lane then runs scripts/assert-test-execution.sh over the
JUnit XML. allTests resolves its members per target on the runner, so a target that cannot run
there drops out of the graph and leaves the job green; the script fails it instead.
No Android emulator lane, deliberately. third_party/rc-embedded-player has no androidTest
source set, so one would boot an emulator to run nothing. Its drawing behaviour is not unverified
for want of a device: the suite is Robolectric-backed and rasterizes through
RcEmbeddedRenderHarness / RcAndroidxEmbeddedRenderHarness, the same lane
scripts/rc-operation-conformance/render-lanes.sh compares pixel-for-pixel against the AndroidX
View player and the CMP player. Revisit this only alongside instrumented tests that assert something
Robolectric cannot — real Skia text shaping, or hardware-accelerated RenderNode behaviour.
./gradlew :rc-player-wasm:wasmPlayerTestDist
npm --prefix scripts/wasm-smoke ci
npm --prefix scripts/wasm-smoke exec -- playwright install chromium
npm --prefix scripts/wasm-smoke testwasmPlayerTestDist stages the optimized bundle next to four .rc documents written by the real
AndroidX writer; scripts/wasm-smoke/smoke.mjs serves that
directory and opens each of them in headless Chromium. A case passes only if the page reaches
data-rc-player-state="ready", publishes the expected embed-contract version on both exports
(window.rcPlayerContractVersion and data-rc-player-contract, which must agree), writes nothing to
console.error, and produces a canvas that is not blank. It also covers the two halves of the embed
contract no Kotlin test can reach — the error marker for a ?src= that 404s, and
window.rcPlayerLoad's warm document swap, which has to repaint the viewport rather than leave the
outgoing document on screen. CHROMIUM_EXECUTABLE points it at a browser Playwright did not install
itself.
This is a different lane from wasmJsBrowserTest, which runs the Kotlin commonTest sources: this
one runs the artifact — the production-compiled rcPlayer.wasm, index.html, the js-joda
import map and the bundled font manifest, exactly as they ship to npm.
./gradlew publishPlayers publishes the Maven artifacts; publishPlayersToMavenLocal is the local
equivalent.
The version line continues compose-ai-tools'. rc-player-trace, -protocol, -runtime,
-compose and third-party-rc-embedded-player were published from there through 1.54.0, so this
repository's manifest starts at 1.54.0 and the first release cut here is 1.55.0. Restarting at
0.1.0 would have published a version below what consumers already resolve — a downgrade to
anything using a range or a BOM, and silently invisible to everything else. rc-player-wasm-dist
and remote-compose-player-js-dist are new coordinates and first appear at that release.
third-party-rc-embedded-player-jvm was published from here for a while and no longer is: the
vendored player's JVM cut was removed, and a consumer that needs a JVM player should depend on
rc-player-compose. The version comes from PLUGIN_VERSION in the environment, or from
.release-please-manifest.json bumped to the next patch -SNAPSHOT for local builds. The npm bundle
and the XCFramework are assembled by the release workflow, which is the only thing that should write
Package.swift.
These players were extracted from yschimke/compose-ai-tools,
which now consumes them as published artifacts. This repository consumes two data extractors in
turn — data-fonts-google (one downloadable-font cache, shared by every lane) and
data-layoutinspector-connector (the production compose/figma-svg export) — which came back
from compose-ai-tools until they moved to
yschimke/compose-preview-daemon with the
rest of the extractors (compose-ai-tools#5336). So the edges are: compose-ai-tools → rc-players,
and rc-players → compose-preview-daemon, which depends on neither. Both are released coordinates
(composeai-preview-daemon in gradle/libs.versions.toml pins the second), so nothing is a
build-time cycle.
Apache 2.0. The vendored AndroidX sources under third_party/ carry their upstream Apache 2.0
licence and provenance; the font faces under rc-player/wasm/dist-assets/fonts/ carry theirs
alongside them.
Remote Compose players — the runtimes that read a .rc document and draw it.
A .rc document is Remote Compose's
wire format: a compact, versioned operation stream that describes a piece of UI without shipping
code. This repository holds the players that interpret one, on every surface this stack targets, plus
the vendored AndroidX player they are all measured against.
Six lanes are listed because implementation, packaging, hosting, and comparison answer different questions. The supported renderer remains CMP; UIKit is an explicitly experimental second renderer.
| Player | Target | Language / runtime | Supported? | Why it exists |
|---|---|---|---|---|
CMP player (rc-player/compose) |
JVM · Android · iOS (iosArm64, iosSimulatorArm64) · macOS (macosArm64) · wasmJs
|
Kotlin Multiplatform + Compose Multiplatform | Yes — the one supported API here | The player written here. One implementation that draws a document natively on every surface this stack targets, with a platform-neutral wire model underneath it. This is what a consumer should depend on. |
Wasm host (rc-player/wasm) |
Browser | The CMP player compiled to WebAssembly | Yes, as an embed contract | Makes the CMP player renderable in a page with no server: an iframe driven by query parameters and window.rcPlayerLoad / window.rcPlayerLoadBytes, or by postMessage from another origin. It is the CMP player, not a second implementation — same pixels, different host. |
Apple XCFramework (Package.swift) |
iOS + Apple-silicon macOS, from Swift | The CMP player's native Apple targets, packaged for SwiftPM | Yes | Same code again, reachable from a Swift app that does not build Kotlin. Distribution, not implementation. |
Native UIKit POC (Sources/RcNativePlayerUIKit) |
iOS | Pure Swift + UIKit/Core Graphics/Core Text | Experimental | A second renderer beside CMP. It explores an idiomatic native component tree and reports unsupported operations explicitly; it is not a replacement or compatibility claim. |
macOS player app (samples/macos-player) |
Apple-silicon macOS | SwiftUI host + CMP or experimental AppKit-native rendering | Release utility | Opens local .rc files and persists a renderer preference, so both Apple desktop paths can be exercised from one downloadable app. |
Vendored AndroidX player (third_party/rc-embedded-player) |
Android (Robolectric) | Kotlin + Compose, vendored from androidx-main | No — testing only | The comparison lane. AndroidX's own embedded player, pinned to one commit and locally patched, so a parity number is attributable to a known player rather than to whichever alpha resolved that day. |
Vendored TypeScript player (third_party/remote-compose-player) |
Browser · Node · VS Code webview | TypeScript → Canvas2D, WebGL for shader ops | No — vendored, upstream elsewhere | A client-side lane that needs no Kotlin at all, so a viewer can render a captured .rc without a server-side daemon. Upstream is yschimke/remotecompose-experiments; changes are filed there. |
The shape of the whole thing: one supported implementation, one experimental renderer, two CMP hosts, and two references. The CMP player is the product; the Wasm bundle and XCFramework are it in different wrappers. The native UIKit POC sits beside it, while the vendored AndroidX player and the TypeScript player remain comparison lanes. There is no separate JVM reference player: the vendored player's JVM cut was removed on 2026-09-25, and where a JVM player is needed, the CMP player is it.
Every tool that lets you choose a player — compose-preview serve's ?rcPlayer=, the daemon's
RemoteComposeOverride.playerId, a capture's capturePlayer, the rc-compare columns — names it
with one of these ids. The id names the implementation that draws, and cmp- means the CMP
player above and nothing else: cmp-android, cmp-jvm and cmp-wasm are one codebase on three
hosts.
| id | Implementation | Where it runs | Also accepted |
|---|---|---|---|
cmp-android |
CMP player (rc-player-compose) on Android |
the preview daemon, under Robolectric | — |
cmp-jvm |
CMP player on the desktop JVM, over Skiko |
compose-preview serve's rc-render-jvm subprocess |
rcplayer-jvm |
cmp-wasm |
CMP player compiled to Wasm (rc-player/wasm) |
the browser | rcplayer-wasm |
androidx-embedded |
Vendored AndroidX embedded player (third_party/rc-embedded-player) |
the preview daemon, under Robolectric | embedded |
androidx-view |
AndroidX remote-player-view RemoteComposePlayer (an Android View) |
the preview daemon, under Robolectric |
java, view
|
camaelon-js |
Vendored TypeScript player (third_party/remote-compose-player) |
the browser | js |
androidx-view is not in this repository: it is AndroidX's own published player, drawn by the
daemon for comparison. The two AndroidX players are the only ones a capture bakes through, so a
capture records androidx-embedded or androidx-view.
cmp-android used to mean the AndroidX embedded player, and some published links and capture
sidecars still say it with that meaning. As a request it now selects the CMP player. In a
capturePlayer field the old meaning is still read as androidx-embedded, because the CMP player
never captures, so that field cannot hold the new one. The same goes for the bare cmp, which is
no longer accepted anywhere: it cannot say which of the three hosts it means.
The supported API, published to Maven Central. Four modules, bottom-up — each is the base of the next, and the dependency arrow is also the dependency-weight arrow:
| Module | Artifact | What it is |
|---|---|---|
rc-player/trace |
rc-player-trace |
The tracing seam. An expect/actual facade so the stack above can open spans on any target. |
rc-player/protocol |
rc-player-protocol |
The .rc wire codec and operation model: reader, writer, immutable operation IR, and the generated AndroidX operation inventory. No Compose dependency at all — usable from a build tool or a server. |
rc-player/runtime |
rc-player-runtime |
Document semantics with no drawing: expression evaluation, animation timelines, layout tree, theming, named values. Still no Compose. |
rc-player/compose |
rc-player-compose |
The Compose Multiplatform renderer. RcComposePlayer draws a document; this is the module most consumers want. |
Beside the stack, and depending on protocol and runtime rather than the other way round:
| Module | Artifact | What it is |
|---|---|---|
rc-player/edit |
rc-player-edit |
Rewrites over a decoded document, for code that changes a .rc file rather than plays it: embed URL bitmaps inline, hardcode host-bound named values, collapse state layouts whose index can no longer change to their one live branch — when the hidden branches hold only layout, modifiers, drawing and touch handlers, so nothing outside can depend on them — fold and strip what a document no longer needs, and check or adapt a document for the player it targets. No Compose. |
val document = RcDocumentCodec.decode(bytes)
val baked =
document
.embedBitmapUrls { url -> httpClient.get(url).readBytes() } // may suspend
.hardcodeNamedValues(mapOf("tab" to RcHardcodedValue.Integer(2)))
.hardcodeUnboundNamedValues(bound = setOf("progress"))
.collapseConstantStateLayouts()
.optimize()
File("baked.rc").writeBytes(RcDocumentCodec.encode(baked))optimize() makes a document smaller and cheaper to play without changing what it plays: it
strips remarks and debug messages, folds every value producer whose value can never change into a
constant (integer and float expressions, colour expressions and the channels read from a colour,
merged, formatted and looked-up text — each evaluated by the player itself), drops repeated
identical definitions, and removes definitions nothing reads. sizeReport() shows where the bytes
are. Over the catalog corpus that is 7% (1.69 MB to 1.59 MB), every document checked to give every
remaining value exactly what the original did.
It pays most on a widget whose host values are baked in, as above: hardcoding turns the named
colours and values a document reads into constants, and optimize() after it turns what is
derived from them into constants too. Over the corpus, hardcoding every named value and
optimising takes it to 1.47 MB (13% smaller), folds nine in ten colour channel reads and two
thirds of the colour expressions, and cuts the time the player spends computing values each frame by about a
third.
It also checks a document against the player it is meant for. RcTargetProfiles names the
profiles AndroidX's creation API writes for — WIDGETS_V6 (launcher widgets on Android 16),
WIDGETS_V7, ANDROIDX, ANDROIDX_EXPERIMENTAL, WEAR_WIDGETS — with operation sets transcribed
from AndroidX's own tables; RcTargetProfile.of(apiLevel, profiles) builds any other.
val wear = RcTargetProfiles.WEAR_WIDGETS
val document = wear.decode(bytes) // resolves `Skip` guards as the Wear player does
document.validateFor(wear) // every operation, field and header the player would reject
val adapted = document.adaptTo(wear) // inline macros, fold constants, re-declare the header
if (!adapted.isPlayable) println(adapted.violations)A target without CoreText — Android 16 launcher widgets, Wear widgets — gets the older
TextLayout wherever it can say the same thing. Material typography also sets letter spacing,
line height and font axes, which TextLayout has no field for:
adaptTo(target, approximateText = true) drops those and lists each drop in
RcProfileAdaptation.approximations; underline, strikethrough and autosize still block. To serve
older devices a different design instead, keep a separate template per profile and check each with
validateFor.
Neither API level 6 nor Wear has ColorTheme, the light/dark pair of system colours.
adaptTo(target, setOf(RcApproximation.THEME_COLORS)) replaces each one with its recorded
fallbacks (colorThemesAsFallbacks()): in theme sections, the way AndroidX's level 6 widget writer
writes a themed colour, or as the dark fallback on Wear. The device's palette is lost. Evidence is
under
renders/rc-edit-profile-fallbacks/.
There are no Apple x86_64 slices anywhere in the stack — Compose Multiplatform 1.11 stopped publishing them — so Intel iOS simulators and Intel Macs cannot build against it.
All five run explicitApi() and an ABI dump gate: checkKotlinAbi diffs the real public surface
against the committed dumps in each module's api/, so a change to the published API shows up as a
diff in review. Regenerate with ./gradlew updateKotlinAbi.
Published twice, because its two consumers resolve differently and neither channel substitutes for the other:
@yschimke/remote-compose-player-cmp — how a web page consumes it;rc-player-wasm-dist (a zip, dist classifier) — how a Gradle build stages it as a
static sidecar.Both are cut from wasmPlayerDist, which enforces a size budget — a ratchet, not a target, so an
unintended jump fails the build before an irreversible publish.
A page can include the player with one script tag: rc-cmp-player.js, shipped in the
distribution beside index.html, owns the iframe and exposes RcCmp.createPlayer(...) and an
<rc-cmp-player> element, the same shape as the TypeScript player's RC.createPlayer and
<rc-player> (embed contract). The drag-and-drop comparison page
(samples/web-compare) includes both libraries and draws a dropped .rc
file in each; stage and serve it with:
build-brief ./gradlew :rc-player-wasm:rcCompareSite
python3 -m http.server -d rc-player/wasm/build/rcCompareSite 8000 # open http://localhost:8000
The distribution carries its own font faces (rc-player/wasm/dist-assets/) because the lane is
manifest-only and never fetches: a family the bundle does not carry fails the availability check
outright.
rc-player/compose's iOS and Apple-silicon macOS targets, assembled into
RcComposePlayer.xcframework and distributed through Swift Package Manager. Consume it by a
bare X.Y.Z tag — the release job writes the
real url and checksum into Package.swift and publishes that commit as the bare tag once the
XCFramework is uploaded. The copy on main is a permanent placeholder: a manifest cannot state the
checksum of an asset that does not exist yet, so main and the v-prefixed tag both fail the
checksum check by design. Editing the two values by hand is never right. Usage is documented, and
type-checked in CI, in docs/design/RC_PLAYER_SWIFT.md.
The manifest is swift-tools-version: 6.0 and builds every target in the Swift 6 language mode
(complete strict concurrency), so it needs a Swift 6 toolchain — Xcode 16 or newer; the deployment
floor stays iOS 13, macOS 12 and visionOS 1.
Application targets normally select the RcComposePlayerSwiftUI product for direct Data, typed
events/errors, and SwiftUI/UIKit entry points; the RcComposePlayer product remains the raw binary
interop surface.
The experimental RcNativePlayerUIKit product is published by the same bare version tag. Each
GitHub Release also carries RcNativePlayerUIKit.swiftpackage.zip and its SHA-256 file: a
self-contained, pure-Swift local package containing the native sources with no binary target. It
gives evaluators a small pinned, offline-resolvable artifact without changing the supported CMP
product.
The native player's tests are two SwiftPM test targets written with swift-testing:
RcNativePlayerCoreTests for the pure-Swift document core and RcNativePlayerUIKitTests for the
platform layer. Run them on the Mac with RC_COMPOSE_PLAYER_NATIVE_ONLY=1 swift test — native-only
mode keeps the Kotlin binary target out of the package graph — and on an iOS simulator with
scripts/check-native-swift-ios-tests.sh. On Linux the manifest declares only the core and its
tests, so swift test --filter RcNativePlayerCoreTests runs there too.
samples/apple-player is the full application check: a SwiftUI document
player linked against the current release XCFramework build, built in CI for the arm64 iOS
Simulator, and
also runnable as a Designed for iPad desktop app on Apple-silicon Macs. It exercises application
integration that the generated-header sample cannot, including bundled and imported .rc files,
the UIKit controller lifecycle, required plist settings, and the framework's architecture matrix.
None of these is a supported API. Provenance, the pinned upstream commit and the full patch log for
the AndroidX ones: third_party/rc-embedded-player/PROVENANCE.md;
for the TypeScript one, third_party/remote-compose-player/PROVENANCE.md.
The AndroidX player is vendored rather than resolved from the alpha coordinates on purpose, and it
is worth being clear that this is not stubbornness about a fork. Upstream's
remote-player-compose snapshot does now ship the embedded player. The problem is that it ships it
into the same package these sources used to occupy, so which bytes ran was decided by classpath
ordering — and on 2026-08-22 upstream reshaped the entry point and every live render died with a
NoSuchMethodError. These sources moved to ee.schimke.composeai.rcembedded.player, a package
nobody else publishes into, so both copies can sit on one classpath and a comparison number is
attributable by construction rather than by luck.
The local deltas over upstream are tracked as issues on this repository and are meant to shrink: two of the original five were dropped when alpha17 restored them upstream.
scripts/rc-lane-ab/ renders the focused Android View/vendored-embedded A/B, while
scripts/rc-operation-conformance/render-lanes.sh sends one manifest and the same .rc bytes
through AndroidX View, released and/or snapshot AndroidX embedded, vendored Android embedded, and
CMP JVM, then validates every result and writes all pairwise pixel
scores to comparison.json. On macOS, --native-uikit adds the packaged simulator player to that
same manifest contract. scripts/check-native-uikit-comparison.sh is the focused five-document
CMP/UIKit regression used by CI. scripts/rc-text-metrics/ does the same for the focused
text-metrics set. Committed visual evidence is under renders/.
rc-player/compat-tests generates .rc fixtures with the real AndroidX writer, so the CMP reader is
tested against documents it did not produce. rc-player/profile and rc-player/metrics are the
profiling and text-metric harnesses. None of the three publishes anything.
For an Apple-only, simulator-oriented CMP/UIKit comparison, see
docs/design/APPLE_PLAYER_BENCHMARKS.md.
rc-conformance scores a player against AndroidX's own conformance corpus — 252 golds over 18
subsystems, each carrying its own compiled document — and writes conformance-results.json plus a
scorecard. It is how "how conformant is this player?" gets a number attached to it, against one
published reference rather than against the other lanes here. The corpus is upstream-unmerged and
therefore lives on the long-lived vendor/androidx-rc-conformance branch rather than on main; the
module resolves it as configuration, never by path. The design, the current CMP score and the work
list are in docs/design/RC_CONFORMANCE.md.
rc-player/demos is the host half of a custom component, which is the one part of the stack the
published API cannot show on its own: the player draws a Custom component only if the host
registers a renderer for its config name. Two are demonstrated — SupportSpannableString, which the
player ships (RcSpannableString) because it is AndroidX's contract, and an editable text field,
whose keystrokes go back into the document through a TEXT_RETURN channel. ./gradlew :rc-player-demos:run opens both in a window, the @Preview functions render in the IDE, and
RcDemoRenderTest rasterizes them headless (renders/rc-custom-components/). It publishes nothing
either.
dependencies {
implementation("ee.schimke.composeai:rc-player-compose:<version>")
}rc-player-compose pulls the rest of the stack transitively. Take rc-player-protocol on its own
when you only need to read or write the wire format.
URL and file bitmap payloads load asynchronously through Coil 3 by default, using the app's
SingletonImageLoader and its caches. The player includes Ktor networking with JVM/Android,
Apple and browser engines. Inline bitmap decoding stays in the player. Pending requests cancel
when the document or loader changes or the player leaves composition.
Override image loading for one player, or provide LocalRcImageLoader around several players:
val images = RcImageLoader { bitmap -> hostImages.load(bitmap.data.decodeToString()) }
RcComposePlayer(document, imageLoader = images)
CompositionLocalProvider(LocalRcImageLoader provides images) {
RcComposePlayer(document)
}An override returns a Compose ImageBitmap, or null for an unavailable image. It owns its access
policy and recoverable errors; it must not block the composition dispatcher. Use
RcImageLoader.Empty to disable external loading. To keep Coil while configuring authentication,
fetchers or caching, configure Coil's singleton before the first player is composed.
rememberCoilRcImageLoader() also returns the default adapter explicitly. Support reports remain
conservative until image ids are supplied through availableImageIds.
Compose dependency versions are consumer requirements, including implementation dependencies
published at runtime scope. The experimental third-party-rc-embedded-player now requests AndroidX
Compose 1.12.1, intentionally matching the CMP player's resolved Android Compose UI floor.
Renovate permits stable 1.12.x patches for these core coordinates; moving to a newer minor line
requires a coordinated compatibility review. Before this update its 1.10.0 direct pins already resolved Compose UI to
1.11.4 through Remote Compose 1.0.0-alpha19; the direct pins were not a promise of 1.10 runtime
compatibility. Compose UI 1.12.1's AAR metadata requires compileSdk 37 and AGP 9.1.0 (other
transitive artifacts can impose additional requirements).
An application declaring an older Compose version will normally resolve the higher transitive version. Strict older constraints can instead fail resolution; forcing or excluding dependencies to retain an older runtime is unverified and can cause linkage failures. This update is therefore unsuitable for clients that must remain on Compose 1.11 or earlier. See Gradle's conflict-resolution rules.
The supported rc-player-compose artifact uses the separate Compose Multiplatform dependency line
and androidx-compose-for-cmp Android pin; changing the vendored player's androidx-compose pin
does not change that artifact's requirements. It already requests AndroidX Google Fonts 1.12.1,
so it likewise does not promise support for an older forced Android Compose runtime. The protocol,
runtime and trace modules do not gain Compose dependencies from this update.
The common Compose API also supports host-rendered custom components. A document names a component
through LAYOUT_CUSTOM; the host registers Compose content under that name. Because the content is
inserted into the player's existing Compose tree, it can be a native control, a named slot, or
another RcComposePlayer. This is the same API on Android, JVM, iOS and Wasm—the browser bundle is
only one possible host.
The implementation and cross-player conformance roadmap for completing the operation set on iOS
and macOS is in
docs/design/RC_CMP_APPLE_OPERATION_PLAN.md.
lateinit var components: RcCustomComponentRegistry
components =
RcCustomComponentRegistry(
"slot:hero" to { _, modifier -> Hero(modifier) },
"rc:document" to { component, modifier ->
childDocuments[component.text(DOCUMENT_KEY)]?.let { child ->
RcComposePlayer(child, modifier, theme = theme, customComponents = components)
}
},
)
RcComposePlayer(compositeDocument, customComponents = components)Custom properties can contain literals, live float references, text references and declared
float/text return channels. See
docs/design/RC_COMPOSITION.md for the composite-document, slot and
state-ownership model.
Custom component renderers can display document children using component.Child(index, modifier)
or component.Children(). component.childCount includes all direct slots;
component.customChild(index) exposes a custom child's properties even when that child's config
has no registered renderer. See the upstream adaptation review.
Swift Package Manager, by the bare version tag — not main, and not the v-prefixed release
tag, neither of which carries a resolvable checksum:
.package(url: "https://github.com/yschimke/rc-players.git", from: "<version>")./gradlew build # everything
./gradlew allTests # the test suites
./gradlew ktfmtFormatAll # format before committing — CI gates on ktfmtCheckAllbuild-brief (static-var/build-brief,
MIT, a single Go binary with no runtime dependencies) sits in front of Gradle, writes every line
Gradle emits to a log file, and prints only what changes the next step: status, failed tasks, failed
tests, warnings, build scan URLs, and generated artifact paths. Gradle's exit code passes through
unchanged, so it is safe anywhere a bare ./gradlew was used.
brew install static-var/tap/build-brief # or: curl -fsSL https://bb.staticvar.dev/install.sh | bash
build-brief doctor # read-only; never runs GradleThen wrap the commands above, for example build-brief ./gradlew build or build-brief ./gradlew allTests. The managed block at the end of AGENTS.md carries the per-command rules and
can be regenerated with build-brief --install.
On a shared developer host, agents use scripts/agent-gradle.sh instead of invoking that wrapper
directly. Use its normal profile for focused work and scripts/agent-gradle.sh --exclusive allTests
for broad multiplatform, Wasm, or Apple task graphs. The exclusive profile uses the same machine lock
as the other Compose Preview repositories; interactive commands and hosted CI bypass it. Pass the
Apple link's required --max-workers=1 normally—the launcher preserves narrower explicit limits.
Worth knowing here specifically:
tasks, projects, dependencies, and dependencyInsight keep
their report bodies.--ci to local commands. It is opt-in and is not implied by the environment; this
repository's workflows continue to invoke Gradle directly..build-brief.json is available for project-specific regex matches but is intentionally absent
until this repository has a result line worth surfacing.An Android SDK is needed for third_party/rc-embedded-player and for the published rc-player/*
modules' Android target (ANDROID_HOME, or sdk.dir in local.properties). The Apple targets only build on macOS; on Linux the Kotlin Gradle plugin disables
them with a warning rather than failing.
-Pcomposeai.remoteCompose=snapshot swaps the pinned AndroidX Remote Compose alphas for the
androidx-main post-submit build pinned in settings.gradle.kts. Use it to exercise an API that has
landed upstream but has not been released; the default release line is what CI and published
consumers see.
Two lanes in .github/workflows/ci.yml, split by what the host can do
rather than by what the change touched:
Linux (ubuntu-latest) |
macOS (macos-15) |
|
|---|---|---|
JVM suites for the four published modules, compat-tests, metrics, profile
|
✅ | ✅ (via allTests) |
Android host tests for all four published modules (compose under Robolectric, including a remote-m3 catalog sample) |
✅ | |
Vendored Android player, Robolectric (testDebugUnitTest) |
✅ | |
| Vendored TypeScript player | ✅ | |
macosArm64Test, iosSimulatorArm64Test, wasmJsBrowserTest
|
✅ | |
iosArm64 — device |
compiled only; a hosted runner has no device to run it on | |
| The shipped Wasm bundle, in a real browser | ✅ scripts/wasm-smoke
|
|
ABI gate (checkKotlinAbi) |
✅ — the dumps cover the Apple klibs, which only build here | |
Native Swift player tests (RC_COMPOSE_PLAYER_NATIVE_ONLY=1 swift test, and the same target on an iOS simulator) |
✅, path-gated with the row below | |
| XCFramework link, Swift type check, Xcode packaging | ✅, and only when the change can affect them |
Both lanes run on every pull request. Only the last two rows are path-gated (the apple-changes
job): the release link is what makes the macOS lane expensive, and a change that cannot reach the
Apple artifacts should not pay for it. The native Swift tests share that gate because nothing
outside its paths — Sources, Tests/RcNativePlayerCoreTests, Tests/RcNativePlayerUIKitTests, the manifests — can change them. Test execution is never gated — a lane that can only run here is
worth its minutes on every change, and the previous arrangement, where the path filter skipped the
whole job, meant a change confined to rc-player/wasm or third_party/ merged without a single
Kotlin/Native or wasm test running.
Each lane then runs scripts/assert-test-execution.sh over the
JUnit XML. allTests resolves its members per target on the runner, so a target that cannot run
there drops out of the graph and leaves the job green; the script fails it instead.
No Android emulator lane, deliberately. third_party/rc-embedded-player has no androidTest
source set, so one would boot an emulator to run nothing. Its drawing behaviour is not unverified
for want of a device: the suite is Robolectric-backed and rasterizes through
RcEmbeddedRenderHarness / RcAndroidxEmbeddedRenderHarness, the same lane
scripts/rc-operation-conformance/render-lanes.sh compares pixel-for-pixel against the AndroidX
View player and the CMP player. Revisit this only alongside instrumented tests that assert something
Robolectric cannot — real Skia text shaping, or hardware-accelerated RenderNode behaviour.
./gradlew :rc-player-wasm:wasmPlayerTestDist
npm --prefix scripts/wasm-smoke ci
npm --prefix scripts/wasm-smoke exec -- playwright install chromium
npm --prefix scripts/wasm-smoke testwasmPlayerTestDist stages the optimized bundle next to four .rc documents written by the real
AndroidX writer; scripts/wasm-smoke/smoke.mjs serves that
directory and opens each of them in headless Chromium. A case passes only if the page reaches
data-rc-player-state="ready", publishes the expected embed-contract version on both exports
(window.rcPlayerContractVersion and data-rc-player-contract, which must agree), writes nothing to
console.error, and produces a canvas that is not blank. It also covers the two halves of the embed
contract no Kotlin test can reach — the error marker for a ?src= that 404s, and
window.rcPlayerLoad's warm document swap, which has to repaint the viewport rather than leave the
outgoing document on screen. CHROMIUM_EXECUTABLE points it at a browser Playwright did not install
itself.
This is a different lane from wasmJsBrowserTest, which runs the Kotlin commonTest sources: this
one runs the artifact — the production-compiled rcPlayer.wasm, index.html, the js-joda
import map and the bundled font manifest, exactly as they ship to npm.
./gradlew publishPlayers publishes the Maven artifacts; publishPlayersToMavenLocal is the local
equivalent.
The version line continues compose-ai-tools'. rc-player-trace, -protocol, -runtime,
-compose and third-party-rc-embedded-player were published from there through 1.54.0, so this
repository's manifest starts at 1.54.0 and the first release cut here is 1.55.0. Restarting at
0.1.0 would have published a version below what consumers already resolve — a downgrade to
anything using a range or a BOM, and silently invisible to everything else. rc-player-wasm-dist
and remote-compose-player-js-dist are new coordinates and first appear at that release.
third-party-rc-embedded-player-jvm was published from here for a while and no longer is: the
vendored player's JVM cut was removed, and a consumer that needs a JVM player should depend on
rc-player-compose. The version comes from PLUGIN_VERSION in the environment, or from
.release-please-manifest.json bumped to the next patch -SNAPSHOT for local builds. The npm bundle
and the XCFramework are assembled by the release workflow, which is the only thing that should write
Package.swift.
These players were extracted from yschimke/compose-ai-tools,
which now consumes them as published artifacts. This repository consumes two data extractors in
turn — data-fonts-google (one downloadable-font cache, shared by every lane) and
data-layoutinspector-connector (the production compose/figma-svg export) — which came back
from compose-ai-tools until they moved to
yschimke/compose-preview-daemon with the
rest of the extractors (compose-ai-tools#5336). So the edges are: compose-ai-tools → rc-players,
and rc-players → compose-preview-daemon, which depends on neither. Both are released coordinates
(composeai-preview-daemon in gradle/libs.versions.toml pins the second), so nothing is a
build-time cycle.
Apache 2.0. The vendored AndroidX sources under third_party/ carry their upstream Apache 2.0
licence and provenance; the font faces under rc-player/wasm/dist-assets/fonts/ carry theirs
alongside them.