feat: desktop-only build isolation for Flatpak packaging (#5360)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Austin <vidplace7@gmail.com>
This commit is contained in:
James Rich
2026-05-06 11:43:35 -05:00
committed by GitHub
parent 94e3901bd4
commit 086c9afbaf
17 changed files with 241 additions and 46 deletions

View File

@@ -22,6 +22,7 @@ import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.meshtastic.buildlogic.isDesktopOnly
import org.meshtastic.buildlogic.library
import org.meshtastic.buildlogic.libs
@@ -49,7 +50,10 @@ class AndroidRoomConventionPlugin : Plugin<Project> {
extensions.configure<KotlinMultiplatformExtension> {
sourceSets.getByName("commonMain").dependencies { implementation(roomRuntime) }
}
dependencies { add("kspAndroid", roomCompiler) }
if (!isDesktopOnly) {
dependencies { add("kspAndroid", roomCompiler) }
}
dependencies { add("kspJvm", roomCompiler) }
}
pluginManager.withPlugin("org.jetbrains.kotlin.android") {

View File

@@ -19,6 +19,7 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.meshtastic.buildlogic.isDesktopOnly
import org.meshtastic.buildlogic.library
import org.meshtastic.buildlogic.libs
@@ -60,12 +61,14 @@ class KmpFeatureConventionPlugin : Plugin<Project> {
implementation(libs.library("compose-multiplatform-ui-tooling-preview"))
}
sourceSets.getByName("androidMain").dependencies {
// Common Android Compose dependencies
implementation(libs.library("accompanist-permissions"))
implementation(libs.library("androidx-activity-compose"))
if (!isDesktopOnly) {
sourceSets.getByName("androidMain").dependencies {
// Common Android Compose dependencies
implementation(libs.library("accompanist-permissions"))
implementation(libs.library("androidx-activity-compose"))
implementation(libs.library("compose-multiplatform-ui"))
implementation(libs.library("compose-multiplatform-ui"))
}
}
sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) }

View File

@@ -22,6 +22,7 @@ import org.meshtastic.buildlogic.configureGraphTasks
import org.meshtastic.buildlogic.configureKmpTestDependencies
import org.meshtastic.buildlogic.configureKotlinMultiplatform
import org.meshtastic.buildlogic.configureTestOptions
import org.meshtastic.buildlogic.isDesktopOnly
import org.meshtastic.buildlogic.libs
import org.meshtastic.buildlogic.plugin
@@ -29,8 +30,10 @@ class KmpLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = libs.plugin("kotlin-multiplatform").get().pluginId)
apply(plugin = libs.plugin("android-kotlin-multiplatform-library").get().pluginId)
apply(plugin = "meshtastic.android.lint")
if (!isDesktopOnly) {
apply(plugin = libs.plugin("android-kotlin-multiplatform-library").get().pluginId)
apply(plugin = "meshtastic.android.lint")
}
apply(plugin = "meshtastic.detekt")
apply(plugin = "meshtastic.spotless")
apply(plugin = "meshtastic.dokka")
@@ -42,7 +45,9 @@ class KmpLibraryConventionPlugin : Plugin<Project> {
configureKmpTestDependencies()
configureTestOptions()
configureGraphTasks()
configureAndroidMarketplaceFallback()
if (!isDesktopOnly) {
configureAndroidMarketplaceFallback()
}
}
}
}

View File

@@ -21,6 +21,7 @@ import org.meshtastic.buildlogic.configureDokkaAggregation
import org.meshtastic.buildlogic.configureGraphTasks
import org.meshtastic.buildlogic.configureKover
import org.meshtastic.buildlogic.configureKoverAggregation
import org.meshtastic.buildlogic.isDesktopOnly
/**
* Root convention plugin applied to the top-level project.
@@ -34,12 +35,14 @@ class RootConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
require(target.path == ":")
with(target) {
val modules = allModules()
apply(plugin = "org.jetbrains.dokka")
configureDokkaAggregation(ALL_MODULES)
configureDokkaAggregation(modules)
apply(plugin = "org.jetbrains.kotlinx.kover")
configureKover()
configureKoverAggregation(ALL_MODULES)
configureKoverAggregation(modules)
// Register graph tasks on the root project itself
configureGraphTasks()
@@ -56,19 +59,22 @@ class RootConventionPlugin : Plugin<Project> {
* Non-KMP modules simply won't have these tasks, so the path-based dependencies will be silently ignored.
*/
private fun Project.registerKmpSmokeCompileTask() {
val kmp = kmpModules()
tasks.register("kmpSmokeCompile") {
group = "verification"
description = "Compile all KMP modules for JVM and iOS Simulator ARM64 targets."
KMP_MODULES.forEach { path ->
kmp.forEach { path ->
dependsOn("$path:compileKotlinJvm")
dependsOn("$path:compileKotlinIosSimulatorArm64")
if (!isDesktopOnly) {
dependsOn("$path:compileKotlinIosSimulatorArm64")
}
}
}
}
/** All modules included in `settings.gradle.kts`. Update this list when adding or removing modules. */
private val ALL_MODULES =
private val ALL_MODULES_FULL =
listOf(
":app",
":core:api",
@@ -104,9 +110,14 @@ private val ALL_MODULES =
":desktop",
)
/** Android-only modules excluded in desktop-only builds. */
private val ANDROID_ONLY_MODULES = setOf(":app", ":core:api", ":core:barcode", ":feature:widget")
private fun Project.allModules(): List<String> =
if (isDesktopOnly) ALL_MODULES_FULL.filter { it !in ANDROID_ONLY_MODULES } else ALL_MODULES_FULL
/**
* Modules that apply the KMP plugin and should be compiled for JVM + iOS targets. Excludes pure-Android modules (:app,
* :core:api, :core:barcode, :feature:widget) and the desktop JVM-only module.
*/
private val KMP_MODULES =
ALL_MODULES.filter { path -> path !in setOf(":app", ":core:api", ":core:barcode", ":feature:widget", ":desktop") }
private fun Project.kmpModules(): List<String> = allModules().filter { it !in ANDROID_ONLY_MODULES + ":desktop" }

View File

@@ -78,39 +78,48 @@ internal fun Project.configureKotlinMultiplatform() {
// Standard KMP targets for Meshtastic
jvm()
// Configure the iOS targets for compile-only validation
// We only add these for modules that already have KMP structure
iosArm64()
iosSimulatorArm64()
if (!isDesktopOnly) {
// Configure the iOS targets for compile-only validation
// We only add these for modules that already have KMP structure
iosArm64()
iosSimulatorArm64()
// Configure the Android target if the plugin is applied
pluginManager.withPlugin("com.android.kotlin.multiplatform.library") {
extensions.findByType<KotlinMultiplatformAndroidLibraryTarget>()?.apply {
compileSdk = configProperties.getProperty("COMPILE_SDK").toInt()
minSdk = configProperties.getProperty("MIN_SDK").toInt()
// Configure the Android target if the plugin is applied
pluginManager.withPlugin("com.android.kotlin.multiplatform.library") {
extensions.findByType<KotlinMultiplatformAndroidLibraryTarget>()?.apply {
compileSdk = configProperties.getProperty("COMPILE_SDK").toInt()
minSdk = configProperties.getProperty("MIN_SDK").toInt()
// Set the namespace automatically if not already set
if (namespace == null) {
val pkg = this@configureKotlinMultiplatform.path.removePrefix(":").replace(":", ".")
namespace = "org.meshtastic.$pkg"
// Set the namespace automatically if not already set
if (namespace == null) {
val pkg = this@configureKotlinMultiplatform.path.removePrefix(":").replace(":", ".")
namespace = "org.meshtastic.$pkg"
}
}
}
} else {
// In desktop-only mode, create placeholder androidMain/iosMain source sets so
// module build scripts that reference them via the DSL accessor don't fail.
// These source sets are inert — no target compiles them.
sourceSets.apply { create("androidMain") { dependsOn(getByName("commonMain")) } }
}
}
// Disable iOS native test link & run tasks.
// iOS targets exist only for compile-time validation; linking test
// executables is extremely slow and causes `./gradlew test` to hang.
tasks.configureEach {
val taskName = name.lowercase()
if (taskName.contains("iosarm64") || taskName.contains("iossimulatorarm64")) {
val isDisabledIosTask =
(taskName.startsWith("link") && taskName.contains("test")) ||
taskName == "iosarm64test" ||
taskName == "iossimulatorarm64test" ||
taskName.endsWith("testbinaries")
if (isDisabledIosTask) {
enabled = false
if (!isDesktopOnly) {
// Disable iOS native test link & run tasks.
// iOS targets exist only for compile-time validation; linking test
// executables is extremely slow and causes `./gradlew test` to hang.
tasks.configureEach {
val taskName = name.lowercase()
if (taskName.contains("iosarm64") || taskName.contains("iossimulatorarm64")) {
val isDisabledIosTask =
(taskName.startsWith("link") && taskName.contains("test")) ||
taskName == "iosarm64test" ||
taskName == "iossimulatorarm64test" ||
taskName.endsWith("testbinaries")
if (isDisabledIosTask) {
enabled = false
}
}
}
}

View File

@@ -36,6 +36,17 @@ import java.util.Properties
private const val MAX_TEST_RETRIES = 2
private const val MAX_TEST_FAILURES = 10
/**
* `true` when the build should only configure JVM (desktop) targets, skipping Android and iOS.
*
* Activate via environment variable (`DESKTOP_ONLY=true`) or Gradle property (`-Pdesktop.only=true`). This allows
* building in environments without the Android SDK (e.g. Flatpak sandboxes).
*/
val Project.isDesktopOnly: Boolean
get() =
providers.gradleProperty("desktop.only").orNull?.toBoolean() == true ||
providers.environmentVariable("DESKTOP_ONLY").orNull?.toBoolean() == true
val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")

67
scripts/desktop-only-prep.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# desktop-only-prep.sh — Prepare source tree for a desktop-only (JVM) build.
#
# Usage:
# ./scripts/desktop-only-prep.sh
# DESKTOP_ONLY=true ./gradlew :desktop:packageUberJarForCurrentOS
#
# This script comments out Android-specific blocks in module build scripts so
# Gradle can configure without the Android SDK. It is designed for Flatpak and
# other sandboxed Linux packaging environments.
#
# Prerequisites:
# npm install -g @ast-grep/cli
# OR
# pipx install ast-grep-cli
#
# The companion in-code guards in build-logic convention plugins handle:
# - Skipping Android/iOS plugin application (KmpLibraryConventionPlugin)
# - Skipping iOS targets (configureKotlinMultiplatform)
# - Creating a placeholder androidMain source set
# - Excluding Android-only modules from settings.gradle.kts
#
# This script handles what can't be done in-code:
# - `kotlin { android { ... } }` blocks in module build.gradle.kts files
# - `androidMain.dependencies { ... }` blocks with project dependencies to
# excluded modules (e.g., projects.core.barcode, projects.core.api)
#
# To reverse: `git checkout -- .` or rebuild from clean source.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT_DIR="$REPO_ROOT/scripts"
cd "$REPO_ROOT"
if ! command -v sg &>/dev/null; then
echo "ERROR: ast-grep (sg) is required but not found in PATH." >&2
echo " Install: https://ast-grep.github.io/guide/quick-start.html" >&2
exit 1
fi
echo "==> Preparing desktop-only build..."
# Collect target build.gradle.kts files (excluding modules that stay as-is)
mapfile -t FILES < <(find . -name "build.gradle.kts" \
-not -path "./build-logic/*" \
-not -path "./.agent_refs/*" \
-not -path "./coil/*" \
-not -path "./kable/*" \
-not -path "./app/*" \
-not -path "./core/api/*" \
-not -path "./core/barcode/*" \
-not -path "./feature/widget/*" \
-not -path "./desktop/*" \
-not -path "./build/*")
if [[ ${#FILES[@]} -eq 0 ]]; then
echo "WARNING: No build.gradle.kts files found to process." >&2
exit 0
fi
# Apply all ast-grep rules from the rules directory.
# Each rule YAML defines a pattern to match and a fix (comment replacement).
sg scan -c "$SCRIPT_DIR/sgconfig.yml" --update-all "${FILES[@]}"
echo "==> Desktop-only prep complete."
echo " Run: DESKTOP_ONLY=true ./gradlew :desktop:packageUberJarForCurrentOS"

View File

@@ -0,0 +1,10 @@
id: remove-android-block
language: kotlin
severity: warning
message: "Remove android { } block for desktop-only build"
rule:
pattern: |-
android {
$$$BODY
}
fix: "// [desktop-only] android { ... } block removed"

View File

@@ -0,0 +1,10 @@
id: remove-android-device-test
language: kotlin
severity: warning
message: "Remove val androidDeviceTest block for desktop-only build"
rule:
pattern: |-
val androidDeviceTest by getting {
$$$BODY
}
fix: "// [desktop-only] val androidDeviceTest { ... } block removed"

View File

@@ -0,0 +1,10 @@
id: remove-android-host-test
language: kotlin
severity: warning
message: "Remove val androidHostTest block for desktop-only build"
rule:
pattern: |-
val androidHostTest by getting {
$$$BODY
}
fix: "// [desktop-only] val androidHostTest { ... } block removed"

View File

@@ -0,0 +1,7 @@
id: remove-android-imports
language: kotlin
severity: warning
message: "Remove com.android import for desktop-only build"
rule:
pattern: "import com.android.$$$REST"
fix: "// [desktop-only] android import removed"

View File

@@ -0,0 +1,10 @@
id: remove-android-instrumented-test
language: kotlin
severity: warning
message: "Remove val androidInstrumentedTest block for desktop-only build"
rule:
pattern: |-
val androidInstrumentedTest by getting {
$$$BODY
}
fix: "// [desktop-only] val androidInstrumentedTest { ... } block removed"

View File

@@ -0,0 +1,10 @@
id: remove-android-main-deps
language: kotlin
severity: warning
message: "Remove androidMain.dependencies { } block for desktop-only build"
rule:
pattern: |-
androidMain.dependencies {
$$$BODY
}
fix: "// [desktop-only] androidMain.dependencies { ... } block removed"

View File

@@ -0,0 +1,8 @@
id: remove-ksp-android
language: kotlin
severity: warning
message: "Remove kspAndroid configuration for desktop-only build"
rule:
kind: call_expression
regex: "^\"kspAndroid"
fix: "// [desktop-only] kspAndroid config removed"

View File

@@ -0,0 +1,7 @@
id: remove-parcelize-plugin
language: kotlin
severity: warning
message: "Remove parcelize plugin for desktop-only build"
rule:
pattern: "alias(libs.plugins.kotlin.parcelize)"
fix: "// [desktop-only] alias(libs.plugins.kotlin.parcelize)"

2
scripts/sgconfig.yml Normal file
View File

@@ -0,0 +1,2 @@
ruleDirs:
- desktop-only-rules

View File

@@ -77,10 +77,13 @@ toolchainManagement {
}
}
// Desktop-only mode: skip Android-only modules when ANDROID_HOME is unavailable (e.g. Flatpak builds).
// Activate via: DESKTOP_ONLY=true ./gradlew :desktop:packageUberJarForCurrentOS
val desktopOnly =
providers.gradleProperty("desktop.only").orNull?.toBoolean() == true ||
System.getenv("DESKTOP_ONLY")?.toBoolean() == true
include(
":app",
":core:api",
":core:barcode",
":core:ble",
":core:common",
":core:data",
@@ -108,6 +111,14 @@ include(
":feature:settings",
":feature:firmware",
":feature:wifi-provision",
":feature:widget",
":desktop",
)
if (!desktopOnly) {
include(
":app",
":core:api",
":core:barcode",
":feature:widget",
)
}