From 086c9afbafa53cf3851ef94a964030ad8104f717 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 6 May 2026 11:43:35 -0500 Subject: [PATCH] feat: desktop-only build isolation for Flatpak packaging (#5360) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Austin --- .../kotlin/AndroidRoomConventionPlugin.kt | 6 +- .../main/kotlin/KmpFeatureConventionPlugin.kt | 13 ++-- .../main/kotlin/KmpLibraryConventionPlugin.kt | 11 ++- .../src/main/kotlin/RootConventionPlugin.kt | 25 +++++-- .../meshtastic/buildlogic/KotlinAndroid.kt | 61 ++++++++++------- .../buildlogic/ProjectExtensions.kt | 11 +++ scripts/desktop-only-prep.sh | 67 +++++++++++++++++++ .../remove-android-block.yml | 10 +++ .../remove-android-device-test.yml | 10 +++ .../remove-android-host-test.yml | 10 +++ .../remove-android-imports.yml | 7 ++ .../remove-android-instrumented-test.yml | 10 +++ .../remove-android-main-deps.yml | 10 +++ .../desktop-only-rules/remove-ksp-android.yml | 8 +++ .../remove-parcelize-plugin.yml | 7 ++ scripts/sgconfig.yml | 2 + settings.gradle.kts | 19 ++++-- 17 files changed, 241 insertions(+), 46 deletions(-) create mode 100755 scripts/desktop-only-prep.sh create mode 100644 scripts/desktop-only-rules/remove-android-block.yml create mode 100644 scripts/desktop-only-rules/remove-android-device-test.yml create mode 100644 scripts/desktop-only-rules/remove-android-host-test.yml create mode 100644 scripts/desktop-only-rules/remove-android-imports.yml create mode 100644 scripts/desktop-only-rules/remove-android-instrumented-test.yml create mode 100644 scripts/desktop-only-rules/remove-android-main-deps.yml create mode 100644 scripts/desktop-only-rules/remove-ksp-android.yml create mode 100644 scripts/desktop-only-rules/remove-parcelize-plugin.yml create mode 100644 scripts/sgconfig.yml diff --git a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt index c334b1b7f..ff902185b 100644 --- a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt @@ -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 { extensions.configure { 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") { diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt index b551b7155..a12edb1f9 100644 --- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt @@ -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 { 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")) } diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt index 13ad495d9..6cc3eee95 100644 --- a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt @@ -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 { 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 { configureKmpTestDependencies() configureTestOptions() configureGraphTasks() - configureAndroidMarketplaceFallback() + if (!isDesktopOnly) { + configureAndroidMarketplaceFallback() + } } } } diff --git a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt index 75194a0bb..18549205e 100644 --- a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt @@ -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 { 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 { * 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 = + 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 = allModules().filter { it !in ANDROID_ONLY_MODULES + ":desktop" } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index 40d2a499a..7222404d4 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -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()?.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()?.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 + } } } } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt index 3f2afaaf3..05a775e0c 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt @@ -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().named("libs") diff --git a/scripts/desktop-only-prep.sh b/scripts/desktop-only-prep.sh new file mode 100755 index 000000000..0bd0c4189 --- /dev/null +++ b/scripts/desktop-only-prep.sh @@ -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" diff --git a/scripts/desktop-only-rules/remove-android-block.yml b/scripts/desktop-only-rules/remove-android-block.yml new file mode 100644 index 000000000..972b63e3f --- /dev/null +++ b/scripts/desktop-only-rules/remove-android-block.yml @@ -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" diff --git a/scripts/desktop-only-rules/remove-android-device-test.yml b/scripts/desktop-only-rules/remove-android-device-test.yml new file mode 100644 index 000000000..4d653681b --- /dev/null +++ b/scripts/desktop-only-rules/remove-android-device-test.yml @@ -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" diff --git a/scripts/desktop-only-rules/remove-android-host-test.yml b/scripts/desktop-only-rules/remove-android-host-test.yml new file mode 100644 index 000000000..0d68e32b3 --- /dev/null +++ b/scripts/desktop-only-rules/remove-android-host-test.yml @@ -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" diff --git a/scripts/desktop-only-rules/remove-android-imports.yml b/scripts/desktop-only-rules/remove-android-imports.yml new file mode 100644 index 000000000..35060f455 --- /dev/null +++ b/scripts/desktop-only-rules/remove-android-imports.yml @@ -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" diff --git a/scripts/desktop-only-rules/remove-android-instrumented-test.yml b/scripts/desktop-only-rules/remove-android-instrumented-test.yml new file mode 100644 index 000000000..4091c617e --- /dev/null +++ b/scripts/desktop-only-rules/remove-android-instrumented-test.yml @@ -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" diff --git a/scripts/desktop-only-rules/remove-android-main-deps.yml b/scripts/desktop-only-rules/remove-android-main-deps.yml new file mode 100644 index 000000000..3dd1cd72d --- /dev/null +++ b/scripts/desktop-only-rules/remove-android-main-deps.yml @@ -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" diff --git a/scripts/desktop-only-rules/remove-ksp-android.yml b/scripts/desktop-only-rules/remove-ksp-android.yml new file mode 100644 index 000000000..7ea8680c8 --- /dev/null +++ b/scripts/desktop-only-rules/remove-ksp-android.yml @@ -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" diff --git a/scripts/desktop-only-rules/remove-parcelize-plugin.yml b/scripts/desktop-only-rules/remove-parcelize-plugin.yml new file mode 100644 index 000000000..9dd969cda --- /dev/null +++ b/scripts/desktop-only-rules/remove-parcelize-plugin.yml @@ -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)" diff --git a/scripts/sgconfig.yml b/scripts/sgconfig.yml new file mode 100644 index 000000000..0366edac1 --- /dev/null +++ b/scripts/sgconfig.yml @@ -0,0 +1,2 @@ +ruleDirs: + - desktop-only-rules diff --git a/settings.gradle.kts b/settings.gradle.kts index a1b99384a..185a8a65a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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", + ) +}