material3-adaptive rides its own release train upstream (1.3.0-rc01), like material3 does, so a consumer had no way to express its version through the plugin - they would have had to hardcode a number and keep it in sync with the port by hand. Adds composeDesktopNative.composeMaterial3Adaptive alongside the existing compose / composeMaterial3 / composeRuntime accessors, stamped into bridge-version.properties from the version catalog like the others. The README consumer snippet now shows the adaptive coordinates, and calls out that window-core has to be declared DIRECTLY when commonMain touches WindowSizeClass - substituted modules hide their transitives from the common metadata classpath.
Compose Desktop Native Bridge
com.bitsycore.compose-desktop-native.bridge lets an app declare the official Compose
Multiplatform coordinates once in commonMain and run on every platform CMP
supports plus the port's Kotlin/Native desktop targets (mingwX64,
linuxX64, linuxArm64, macosArm64 - no JVM).
Gradle dependency-substitution rules cannot ship inside a Maven artifact, so
this plugin carries them instead: on every configuration belonging to a native
desktop target it substitutes the official org.jetbrains.compose.*
coordinates for the published com.bitsycore.compose.sdl:* klibs. All other
targets (android / jvm / iOS / wasm) keep resolving the official artifacts
untouched. org.jetbrains.compose.runtime is never substituted - the official
runtime klibs serve every target.
Usage
// settings.gradle.kts
pluginManagement {
repositories {
// Port artifacts + bridge plugin, no authentication needed.
maven("https://maven.bitsycore.com/releases")
gradlePluginPortal()
mavenCentral()
}
}
plugins {
id("com.bitsycore.compose-desktop-native.bridge") version "<release-version>"
}
dependencyResolutionManagement {
repositories {
// The port's own artifacts AND the bitsycore skiko fork
// (com.bitsycore.skiko:skiko / :skiko-mingwx64) both live here - public,
// no credentials. Windows (mingwX64) renders through the fork; macOS and
// Linux use the official org.jetbrains.skiko from Maven Central.
maven("https://maven.bitsycore.com/releases")
google()
mavenCentral()
}
}
The port is also published to GitHub Packages (
https://maven.pkg.github.com/bitsycore/compose-desktop-native) as an authenticated fallback - same coordinates, needs a PAT withread:packages.
// module build.gradle.kts - official coords, everywhere.
// Applying the plugin HERE exposes the `composeDesktopNative` extension, which
// reports the exact Compose versions the port tracks, so you never hand-match
// them against compose.properties.
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("com.bitsycore.compose-desktop-native.bridge")
}
kotlin {
macosArm64(); linuxX64(); mingwX64() // + jvm()/android()/ios if you like
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.compose.runtime:runtime:${composeDesktopNative.composeRuntime}")
implementation("org.jetbrains.compose.ui:ui:${composeDesktopNative.compose}")
implementation("org.jetbrains.compose.foundation:foundation:${composeDesktopNative.compose}")
implementation("org.jetbrains.compose.material3:material3:${composeDesktopNative.composeMaterial3}")
}
}
}
composeDesktopNative exposes four read-only values: compose (ui /
foundation / animation / material), composeMaterial3 (versioned separately
upstream), composeRuntime, and version (the port klib version being
substituted). You can still write literal versions if you prefer; the extension
just removes the drift.
Ecosystem libraries
Substitution is not limited to Compose. Several popular libraries stop short of Kotlin/Native desktop upstream; the port vendors them and the bridge swaps them in on native desktop targets, so you declare the official coordinates and they resolve everywhere:
commonMain.dependencies {
// Koin - koin-core already ships desktop-native klibs; these four are the
// ones upstream builds for apple + android only.
implementation("io.insert-koin:koin-core:4.2.2")
implementation("io.insert-koin:koin-compose:4.2.2")
implementation("io.insert-koin:koin-compose-viewmodel:4.2.2")
implementation("io.insert-koin:koin-compose-navigation3:4.2.2")
// Coil 3 - no mingwX64 upstream anywhere, and no desktop native at all for
// its compose layer.
implementation("io.coil-kt.coil3:coil-compose:3.6.2")
implementation("io.coil-kt.coil3:coil-network-ktor3:3.6.2")
implementation("io.coil-kt.coil3:coil-svg:3.6.2")
// Pulse MVI
implementation("com.bitsycore.lib:pulse:0.3.7")
implementation("com.bitsycore.lib:pulse-compose:0.3.7")
}
Two caveats:
- Koin's
koin-compose-viewmodel-navigationis not substituted. It sits on Navigation 2'snavigation-compose, which publishes no mingwX64 or linux klibs under either theandroidx.*ororg.jetbrains.androidx.*coordinates. Usekoin-compose-navigation3instead. - Lifecycle comes from the google
androidx.lifecycle:*coordinates here. If your own build also pulls theorg.jetbrains.androidx.lifecycle:*mirrors, you will get the same classes twice - pick one.
Settings-wide or per-module
The plugin applies at either level:
// settings.gradle.kts - every module of the build
plugins { id("com.bitsycore.compose-desktop-native.bridge") version "<v>" }
// build.gradle.kts - this module only (compose-plugin style)
plugins { id("com.bitsycore.compose-desktop-native.bridge") }
Note the difference between the two settings blocks: a
pluginManagement { plugins { id(...) version ... } } entry only PINS the
version (so module-level applications can omit it); the top-level
plugins { } block in settings is what actually applies it build-wide.
Rule of thumb: single app module → apply in the module; multi-module builds →
apply once in settings.
When applied from settings, the type-safe composeDesktopNative accessor is not
generated in the module scripts. The same values are still available through
project extra properties:
val compose = project.extra["composeDesktopNative.compose"] as String
val material3 = project.extra["composeDesktopNative.composeMaterial3"] as String
val runtime = project.extra["composeDesktopNative.composeRuntime"] as String
compose.desktop.native - the application block for native
The native counterpart of compose.desktop { application { mainClass } }:
compose.desktop {
application { mainClass = "app.MainJvmKt" } // upstream jvm
native { entryPoint = "app.main" } // compose-desktop-native
}
Declares an executable with that entry point on every Kotlin/Native desktop
target - no targets.withType<KotlinNativeTarget> { binaries.executable { … } }
boilerplate. If you declare your own binaries.executable { } (e.g. for extra
linker flags), it keeps everything it configures; entryPoint only fills in
where the executable didn't set one, so the two compose.
App icon
Declare the app icon inside native { } as PNG files:
compose.desktop {
native {
entryPoint = "app.main"
icon {
light.from("icons/app-32.png", "icons/app-128.png")
dark.from("icons/app-dark-32.png", "icons/app-dark-128.png") // optional
// exeIcon.from("icons/app-16.png", …, "icons/app-256.png") // optional
// resourceDir.set("icon") // data.kres subfolder (default)
// embedWindowsIcon.set(true) // embed the .exe icon (default)
}
}
}
The plugin (no Python / native tooling needed - pure-Kotlin codec):
-
decodes each PNG to a raw RGBA blob and bundles it into
data.kresunder<resourceDir>/<pngBaseName>.rgba. Your app selects it at runtime, matched to the OS light/dark theme:nativeComposeWindow( title = "My App", icon = AppWindowIcon( light = listOf("icon/app-128.rgba", "icon/app-32.rgba"), dark = listOf("icon/app-dark-128.rgba", "icon/app-dark-32.rgba"), ), ) { App() }List one path per size you supply - the largest becomes the base and the rest become alternate resolutions SDL picks from (title bar / taskbar / Alt-Tab). The runtime icon uses core SDL only, so it works on every target.
-
on Windows, assembles a multi-size
.icofromexeIcon(orlightwhenexeIconis unset), compiles it withwindres(mingw-w64 binutils), and links it into the.exeso Explorer and the pinned taskbar show it. SetembedWindowsIcon.set(false)to skip this (e.g. ifwindresisn't installed). The resource object is linked into every mingw executable - the one the plugin creates vianative { entryPoint }and hand-declaredbinaries.executable { }ones alike.
The runtime window icon and the .exe icon can differ: point light at a
background-less mark (looks right in the taskbar) and exeIcon at the full
branded icon with a background (stays legible in Explorer) - the same split
apidemo uses.
composeResources - zero setup
If the module also applies the official org.jetbrains.compose plugin, the
bridge completes the resources story on the native desktop targets: it
registers a package<Variant>ComposeResources<Target> task per native
executable that bundles the Compose plugin's prepared resources into
data.kres next to the binary (a STORED zip the port's runtime reads via
SDL_GetBasePath). Files under src/commonMain/composeResources/ + the
generated Res.* accessors then work exactly like on every other platform -
drawables, strings (values/*.xml), fonts, raw files:
commonMain.dependencies {
implementation("org.jetbrains.compose.components:components-resources:<cmp-version>")
}
compose.resources { packageOfResClass = … } is honoured; source-set
overrides follow the default hierarchy (a mingwX64Main resource beats a
commonMain one).
data.kres entries are STORED by default (the runtime reads an entry with one
fseek+fread). Pass -PcompressResources=true (or set it in gradle.properties)
to DEFLATE them for a smaller distributable - the runtime inflates on read.
Notes
- The substituted klib version defaults to the plugin's own version (both ship
from the same tag). Override with
composeDesktopNative.version=<x>ingradle.propertieswhen mixing releases. composeDesktopNative.substitution=falsedisables the substitution half while keeping the packaging / icon /compose.desktop.nativeDSL - for builds that already provide the port modules another way (the port repo itself uses this: its root build substitutes the official coords to project modules).- Use
composeDesktopNative.compose/.composeMaterial3/.composeRuntime(above) for the official coords rather than hardcoding a version. Substitution replaces the requested version on native, but your jvm/android targets resolve the official artifacts at the version you write, so matching what the port tracks keeps every target on the same API. The values come straight from the release the plugin shipped with; no need to readcompose.propertiesby hand. - Requires Gradle 8.8+ when applied in settings (
gradle.lifecycle.beforeProject). - App windowing/main-loop (
com.bitsycore.compose:desktop-native-window) and the icon font module (material-symbols) are the port's own APIs - depend on them directly; no substitution involved.