The first version of this screen proved the vendored adaptive modules render, but it did not really demonstrate adaptation: it left rememberListDetailPaneScaffoldNavigator on its default directive, which is the WINDOW size class, and pinned the scaffold to a fixed 420dp. The window is the wrong input here. The screen does not get the window - the demo shell spends 190dp on the sidebar plus 48dp of padding - so defaulting folds ~240dp too late and leaves two panes side by side in a region already too cramped for them. The directive is now computed from the width the screen actually gets (BoxWithConstraints), which is what any pane-hosting app that is not full-window should do; the pane height tracks the window height instead of being a constant. The header prints the window size class and the pane-area size class side by side so the difference is visible while dragging. Also adds selection highlighting and a Back button that only appears while folded, so the single-pane mode is actually navigable. New `--adaptivetest` probe: drives one window wide -> narrow -> wide with setSize and asserts the scaffold folds and unfolds. This is what proves the resize chain end to end - SDL resize, the owner's snapshot-backed containerSize, recomposition, BoxWithConstraints re-measure, a new PaneScaffoldDirective on the navigator, a new scaffold value. The --screen=Adaptive --width=N screenshots could only ever prove the fold was right for the size the window OPENED at, since each run is a fresh process. The probe reads a small internal seam on the screen (gAdaptiveTwoPane); it is documented as a probe hook and is not UI. Verified on mingwX64: folds at 800dp and stays two-pane at 1100/1400dp, --adaptivetest passes repeatedly, and the JVM parity leg plus commonMain metadata still compile.
14 KiB
Tooling
Helper scripts and workflows used to build, vendor, and verify Compose Desktop Native. Each has a focused job; the deeper ones link to their own README.
For architecture, source-set layout, and vendoring rules, see CLAUDE.md.
Native libraries
SDL3 is built from source as a static library and linked straight into the
executable - the windowing, input, and platform-integration layer. One script
does it on every OS. Output lands in the gitignored libs/; the version is
pinned in scripts/build-sdl/build-sdl.properties.
python3 scripts/build-sdl/build-all.py # sdl3
python3 scripts/build-sdl/build-all.py sdl3 # rebuild the step
Needs git, cmake, and Python 3 on every host (ninja is fetched when
absent). Run it once per machine, or after bumping the pinned version.
The static lib is built slim: the port uses SDL only for video/window,
events, clipboard, file dialogs, GL/Metal contexts, the CPU-raster SDL_Render
blit, filesystem, cursor, locale, theme, and text input/IME. build-all.py
disables every unused subsystem - audio, joystick, haptic, hidapi, sensor,
power, camera, GPU, offscreen, virtual-joystick (all zero-reference), plus
tests/examples and the Windows D3D12 driver. If a consumer app ever needs one of
these (e.g. SDL audio), re-enable its -DSDL_* flag in build-all.py and
rebuild - a build-time change, no code edit.
Vendoring upstream Compose
Most androidx.compose.* code is copied byte for byte from
JetBrains/compose-multiplatform-core into each module's gitignored
src/vendor/. A compose-fork.txt manifest per module lists which upstream
files it pulls; the pinned ref lives in scripts/compose-fork/compose.properties.
scripts/compose-fork/sync.sh # re-sync every module
scripts/compose-fork/sync.sh compose/ui/ui/compose-fork.txt # one module
Reach for this after a fresh checkout or a ref bump. Details: scripts/compose-fork/README.md.
Guardrails that keep the vendor tree honest:
python3 scripts/compose-fork/check-vendor-clean.py # src/vendor matches the pinned refs
python3 scripts/compose-fork/check-vendor-drift.py # hand-edited "manual vendors" match their pin
python3 scripts/compose-fork/audit-exclusions.py # every `!` exclusion is classified
check-vendor-clean fails if a hand-edit ever leaks into src/vendor/.
check-vendor-drift reads the // VENDOR-BASE: header on every edited copy and,
using the local upstream clone, reports whether the base actually changed since
the pin (needs reconciling) or is merely stale (safe to re-stamp).
audit-exclusions covers the other half: a manifest ! line takes a file OUT of
the verbatim sync, and the local replacement is either a derived copy
(// VENDOR-BASE:, drift-tracked) or a fresh reimplementation
(// VENDOR-REIMPL:, deliberately not tracked - only a SIGNATURE change matters).
It classifies each one, flags any local counterpart carrying NEITHER marker
(invisible to the drift check, silently rotting) and any copy that has become
identical to upstream (drop it and re-vendor), and exits non-zero on an
unclassified file. Run all three on every ref bump.
Coverage against upstream
How much of upstream's public API the port covers is measured, not guessed.
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
python3 scripts/compose-coverage.py --missing ui-text # list uncovered declarations
Verification
One-command gate
verify-mac.sh is the runbook to run after any renderer or layout change. On
macOS or Linux it exercises the Skia renderer and exits non-zero on any
failure:
scripts/verify-mac.sh
It runs: the vendor drift and clean checks, a build of :demo and :apidemo,
the interaction probes, the parity sweep, a memory soak, and a frame-time spot
check. The Windows target is verified separately (see below).
Parity: native vs JVM
:demo renders the same shared screens on two stacks: this port, and stock JVM
Compose Desktop. parity.py screenshots every screen on both and pixel-diffs
them, so a screen that diverges is a porting bug. It gates against per-screen
golden baselines.
python3 scripts/parity/parity.py # all screens
python3 scripts/parity/parity.py Buttons Shapes # a subset
python3 scripts/parity/parity.py --no-build # reuse the last renders
How to read the diff and the percentage: scripts/parity/README.md.
Interaction probe
probe.py launches a native window, sends window-relative input (click, hover,
hold), and captures the client area. It packages the rigs used to reproduce
interaction bugs. Windows-only. Details:
scripts/probe/README.md.
Frame profiler
Set CDN_PROFILE=1 (or CDN_PROFILE=<path>) and run any app. Every couple of
seconds it writes per-phase main-loop timings (events, app pump, render, with
draw sub-phases and per-frame draw counters) to a file. CDN_FORCERENDER=1
renders every frame so steady-state timings read on an otherwise idle screen.
CDN_PROFILE=1 CDN_FORCERENDER=1 ./demo/build/bin/macosArm64/debugExecutable/demo.kexe
Note: present is vsync-blocking, so profile on the target refresh rate before
drawing conclusions about a frame-rate gap.
Text metrics diagnostic
Set CDN_TEXT_METRICS=1 and run any app to dump, per built paragraph, the font
scaler's ascent/descent/leading, the resolved familyName + fontPx, and
the resulting paragraph line box (paraHeight, line0[ascent/descent/baseline/ height]). Its job is the mac-vs-Windows vertical-spacing question (PLAN.md ยง1b):
the SAME NotoSans.ttf bytes go through a different Skia FontMgr scaler per
host (CoreText / fontconfig-FreeType / DirectWrite), which can pick different
metric tables. Run it on native AND on the JVM parity app on the SAME host and
diff the lines - Windows-native must match skiko-JVM-Windows.
CDN_TEXT_METRICS=1 demo.kexe --screen=Buttons --screenshot=/tmp/b.bmp
Demo probes
:demo ships headless regression probes driven by CLI flags, used by the
verify runbook and in isolation:
demo.kexe --nav3test # Navigation 3 + ViewModel + lifecycle
demo.kexe --backtest # predictive-back / BackHandler
demo.kexe --clicktest # pointer -> upstream clickable
demo.kexe --scrolltest # wheel -> scrollable
demo.kexe --multiwintest # multi-window lifecycle
demo.kexe --adaptivetest # material3-adaptive folds/unfolds on a LIVE window resize
demo.kexe --soaktest # memory soak (CDN_SOAK_SCREEN, CDN_SOAK_STATIC, CDN_SOAK_CYCLES)
Windows smoke
The Mac runbook cannot cover the Windows target. Before shipping, on a Windows
host: build the mingwX64 executables, run scripts/probe/, and run a publish
dry run (only Windows compiles the full common-metadata variant table).
gradlew.bat :demo:runDebugExecutableMingwX64
gradlew.bat :apidemo:runDebugExecutableMingwX64
mingwX64 renders through Skia via the bitsycore skiko fork, consumed from
the PUBLIC https://maven.bitsycore.com/releases (no credentials) as
com.bitsycore.skiko:skiko:0.150.1-mingw.2 (macOS/Linux use official Skiko). The runtime skiko-windows-x64.dll is auto-provisioned next
to the executable by the bridge plugin - no manual copy. The fork itself is
published by a separate GitHub Actions workflow in the fork repo, out of band
from this repo's release flow.
Consuming the port
To build a third-party app against the published klibs, apply the bridge Gradle plugin and declare official Compose Multiplatform coordinates; the plugin swaps in the port's klibs on native desktop targets. Setup: gradle-plugin/compose-desktop-native-bridge/README.md.
Versioning and releasing
Version map
Versions live in a few places, some of which move in lockstep. There is no single file to edit; know which axis you are changing.
| Version | Where | Notes |
|---|---|---|
| Project release version | The git tag vX.Y.Z. PUBLISH_VERSION (from the tag) feeds vPublishVersion in build.gradle.kts, which strips the leading v. Groups mirror upstream per area (com.bitsycore.compose.<area>:<module>, e.g. com.bitsycore.compose.ui:ui); project-only modules use com.bitsycore.compose.sdl and :desktop-native-window is com.bitsycore.compose - see groupFor() in the root build. |
Set by the tag, not edited by hand. A non-publish build is 0.0.0-SNAPSHOT. |
| Vendored Compose (native side) | COMPOSE_CORE_REF and COMPOSE_REF in scripts/compose-fork/compose.properties, plus compose in gradle/libs.versions.toml. |
Pin to a durable tag (not a +dev commit upstream may GC). Re-sync after changing. |
| Vendored ecosystem libs | KOIN_REF, COIL_REF, PULSE_REF in the same file, plus koin / coil in gradle/libs.versions.toml. |
Same rule: durable tags. Each repo gets its own sibling sparse clone (../cmp-ref-koin, ../cmp-ref-coil, ../cmp-ref-pulse-mvi). Re-sync, then apiDump - these modules are BCV-validated like the rest. |
| JVM parity forcing | compose / composeMaterial3 / composeRuntime in gradle/libs.versions.toml (read by demo, apidemo, material-symbols and the root build's forcing map). |
Must be a version PUBLISHED to Maven Central. It may lag the vendored native ref (a documented skew) until the matching version is published; at v1.12.1 they are in lockstep. |
| Skiko | skiko (official) and skikoMingw (the fork) in gradle/libs.versions.toml; keep the fork's -mingw.N base equal to skiko. |
macOS/Linux use official Skiko (org.jetbrains.skiko); mingwX64 uses the bitsycore fork (com.bitsycore.skiko:skiko:0.150.1-mingw.2 from the public maven.bitsycore.com), published out of band by the fork repo's own workflow. Must expose the org.jetbrains.skiko.node RenderNode / GraphicsContext API the vendored compose-core uses (the fork keeps upstream's org.jetbrains.skiko.* package names; only the Maven coord is rebranded). Verify with a throwaway skikoRendererMain compile if unsure. |
| SDL3 | scripts/build-sdl/build-sdl.properties. |
Rebuild libs/ with build-all.py after any change. |
| Bridge substituted version | Defaults to the bridge plugin's own published version; consumers override with the composeDesktopNative.version Gradle property. |
The plugin publishes with the release tag, so a consumer on the matching plugin version resolves the right klibs automatically. |
Bump the upstream Compose ref
Run this on each upstream bump; it is the flow that keeps the sync tax low.
- Edit the relevant
*_REFincompose.properties-COMPOSE_CORE_REF/COMPOSE_REFfor Compose,KOIN_REF/COIL_REF/PULSE_REFfor the ecosystem libs (and the matching entry inlibs.versions.tomlif the coordinate version moved). scripts/compose-fork/sync.shpython3 scripts/compose-fork/check-vendor-drift.py. For any manual vendor whose upstream base actually changed, reconcile by hand; otherwise re-stamp its// VENDOR-BASE:header to the new ref. Thenpython3 scripts/compose-fork/audit-exclusions.py- a newly-identical copy should go back to verbatim vendoring, and an unclassified one needs a marker. Finish with./gradlew apiDumpand review the klib API diff.- Build and fix any fallout.
scripts/verify-mac.sh(green, including the soak and parity gates).- If a matching Compose version is now published to Maven, bump
vComposeJvmVersionin demo/apidemo/material-symbols to close the skew. - Run WIN-SMOKE on a Windows host.
- Re-seed parity baselines only if metrics legitimately moved
(
parity.py --update-baselines), and record why.
Cut a release
- Complete the ref-bump flow above and confirm it is green. For a STABLE
release,
vComposeJvmVersionshould match the vendored ref (no skew). - On a Windows host, run
gradlew :desktop-native-window:compileCommonMainKotlinMetadata. Only Windows compiles the full common-metadata variant table, and the root KotlinMultiplatform publications live there; a macOS-only publish leaves the roots without mingwX64 variants (this bit v0.1.15). - Refresh the klib API baselines: on a Windows host (JDK 21, matching CI) run
./gradlew apiDumpand commit any diff. Not a hard gate yet - the API is pre-stable andapiCheckisn't wired - but regenerating each release keeps the baselines honest so the eventual gate is a no-op.macosArm64is inferred from the linux/mingw ABIs (accurate for the target-independent Compose surface);:sdl-core(cinterop) and:material-symbols(generated icon maps) are excluded viaapiValidation.ignoredProjects. Best host: Windows - it builds the mingw slice (the fork's unique surface) for real and only infers macos; the macos ABI is target-independent for the tracked pure-Kotlin modules. git tag vX.Y.Z && git push origin vX.Y.Z. This triggers.github/workflows/publish.yml.
What the publish job does, so you can read a failure:
- Each host publishes only its own target's publications to GitHub Packages
under their per-area
com.bitsycore.compose.*coords. Windows additionally publishes the root KotlinMultiplatform metadata, thejvmpublication, and the bridge plugin (only Windows declares every target). - The
MODULESlist in the workflow must stay in sync with the modules insettings.gradle.kts. - A partial publish (for example a dropped connection mid-upload) deletes this host's half-published versions for the tag and retries, so a rerun is safe.
- The demo and apidemo release binaries are zipped and attached to the GitHub Release for the tag.
- Verify a consumer build against the new version resolves through the bridge.