From b9315d4b3ffa04346993f8a9459f4b3c2ec8f108 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 27 May 2026 17:33:35 -0700 Subject: [PATCH] feat: adopt gradle-flatpak-sources plugin for offline Flatpak builds (#5619) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/release.yml | 5 +- .github/workflows/reusable-check.yml | 3 +- .github/workflows/verify-flatpak.yml | 111 ++++++++ .gitignore | 1 + build-logic/flatpak-ops/build.gradle.kts | 81 ------ .../meshtastic/flatpakops/FlatpakOpsPlugin.kt | 254 ------------------ build-logic/settings.gradle.kts | 11 +- build.gradle.kts | 14 +- gradle/build-cache.settings.gradle | 78 ++++++ gradle/develocity.settings.gradle | 92 ------- .../init-scripts/flatpak-ops.init.gradle.kts | 51 ---- gradle/libs.versions.toml | 4 +- scripts/verify-flatpak/verify.sh | 5 +- settings.gradle.kts | 22 +- 14 files changed, 229 insertions(+), 503 deletions(-) create mode 100644 .github/workflows/verify-flatpak.yml delete mode 100644 build-logic/flatpak-ops/build.gradle.kts delete mode 100644 build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt create mode 100644 gradle/build-cache.settings.gradle delete mode 100644 gradle/develocity.settings.gradle delete mode 100644 gradle/init-scripts/flatpak-ops.init.gradle.kts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48a27afb90..d1fa993b3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -371,7 +371,7 @@ jobs: cache_read_only: 'true' # Uses an isolated Gradle user home so every artifact actually traverses the - # network — meshtastic.flatpak-ops captures URLs via BuildOperationListener and + # network — the flatpak-sources plugin captures URLs via BuildOperationListener and # only sees ExternalResourceReadBuildOperation events for cache misses. With the # shared cache, downloads would be skipped and the manifest would be (nearly) empty. # We drive packageUberJarForCurrentOS — the same task the in-flatpak build invokes — @@ -381,11 +381,10 @@ jobs: run: > ./gradlew --no-build-cache --no-configuration-cache -Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home - -I gradle/init-scripts/flatpak-ops.init.gradle.kts :desktopApp:packageUberJarForCurrentOS :captureFlatpakSources - name: Stage manifest - run: cp build/flatpak-ops-sources.json flatpak-sources.json + run: cp build/flatpak-sources.json flatpak-sources.json - name: List Flatpak source files run: ls -l flatpak-sources.json diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 94ea28f9e1..906cc4da12 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -582,11 +582,10 @@ jobs: run: > ./gradlew --no-build-cache --no-configuration-cache -Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home - -I gradle/init-scripts/flatpak-ops.init.gradle.kts :desktopApp:packageUberJarForCurrentOS :captureFlatpakSources - name: Stage manifest - run: cp build/flatpak-ops-sources.json flatpak-sources.json + run: cp build/flatpak-sources.json flatpak-sources.json - run: ls -lah flatpak-sources.json diff --git a/.github/workflows/verify-flatpak.yml b/.github/workflows/verify-flatpak.yml new file mode 100644 index 0000000000..9cf061c1a4 --- /dev/null +++ b/.github/workflows/verify-flatpak.yml @@ -0,0 +1,111 @@ +name: Verify Flatpak Offline Build + +on: + pull_request: + branches: [ main ] + paths: + - 'scripts/verify-flatpak/**' + - 'build.gradle.kts' + - 'settings.gradle.kts' + - '.github/workflows/verify-flatpak.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: flatpak-verify-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + generate-sources: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - uses: gradle/actions/setup-gradle@v4 + + - name: Generate flatpak-sources.json + run: | + ./gradlew --no-build-cache --no-configuration-cache \ + -Dgradle.user.home="$RUNNER_TEMP/flatpak-gradle-home" \ + :desktopApp:packageUberJarForCurrentOS :captureFlatpakSources + cp build/flatpak-sources.json flatpak-sources.json + echo "### Flatpak Sources Summary" >> "$GITHUB_STEP_SUMMARY" + echo "- URLs captured: $(jq length flatpak-sources.json)" >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@v4 + with: + name: flatpak-sources + path: flatpak-sources.json + + build-flatpak: + needs: generate-sources + runs-on: ${{ matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + timeout-minutes: 45 + strategy: + matrix: + arch: [x86_64, aarch64] + fail-fast: false + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - uses: actions/download-artifact@v4 + with: + name: flatpak-sources + + - name: Clone vid's flatpak repo + run: | + git clone --depth 1 --recurse-submodules \ + https://github.com/vidplace7/org.meshtastic.desktop.git \ + "$RUNNER_TEMP/org.meshtastic.desktop" + + - name: Wire overlay manifest + sources + run: | + cp scripts/verify-flatpak/desktop-offline.yaml \ + "$RUNNER_TEMP/org.meshtastic.desktop/org.meshtastic.desktop.yaml" + cp flatpak-sources.json \ + "$RUNNER_TEMP/org.meshtastic.desktop/flatpak-sources.json" + rsync -a --delete \ + --exclude='/build/' --exclude='/.gradle/' \ + --exclude='*/build/' --exclude='*/.gradle/' \ + --exclude='/.idea/' --exclude='/local.properties' \ + ./ "$RUNNER_TEMP/org.meshtastic.desktop/meshtastic-android/" + + - name: Install flatpak-builder + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq flatpak flatpak-builder + flatpak remote-add --user --if-not-exists flathub \ + https://dl.flathub.org/repo/flathub.flatpakrepo + + - name: Build flatpak offline + working-directory: ${{ runner.temp }}/org.meshtastic.desktop + run: | + flatpak-builder --user --repo=repo --install-deps-from=flathub \ + --force-clean builddir org.meshtastic.desktop.yaml + + - name: Export .flatpak bundle + working-directory: ${{ runner.temp }}/org.meshtastic.desktop + env: + ARCH: ${{ matrix.arch }} + run: | + flatpak build-bundle repo org.meshtastic.desktop.${ARCH}.flatpak \ + org.meshtastic.desktop \ + --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo + echo "### ✅ Offline Flatpak build succeeded ($ARCH)" >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@v4 + with: + name: meshtastic-desktop-flatpak-${{ matrix.arch }} + path: ${{ runner.temp }}/org.meshtastic.desktop/org.meshtastic.desktop.${{ matrix.arch }}.flatpak diff --git a/.gitignore b/.gitignore index b491269206..18fe71d64b 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ flatpak-sources-*.json flatpak-sources.json offline-repository/ .claude/ +build-scan-*.scan diff --git a/build-logic/flatpak-ops/build.gradle.kts b/build-logic/flatpak-ops/build.gradle.kts deleted file mode 100644 index 04ed7143a9..0000000000 --- a/build-logic/flatpak-ops/build.gradle.kts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - `kotlin-dsl` - alias(libs.plugins.spotless) - alias(libs.plugins.detekt) -} - -group = "org.meshtastic.flatpakops" - -java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 -} - -kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 } } - -dependencies { - implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) - detektPlugins(libs.detekt.formatting) -} - -tasks { - validatePlugins { - enableStricterValidation = true - failOnWarning = true - } -} - -spotless { - ratchetFrom("origin/main") - kotlin { - target("src/*/kotlin/**/*.kt", "src/*/java/**/*.kt") - targetExclude("**/build/**/*.kt") - ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) } - ktlint(libs.versions.ktlint.get()) - .setEditorConfigPath(rootProject.file("../config/spotless/.editorconfig").path) - licenseHeaderFile(rootProject.file("../config/spotless/copyright.kt")) - } - kotlinGradle { - target("**/*.gradle.kts") - ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) } - ktlint(libs.versions.ktlint.get()) - .setEditorConfigPath(rootProject.file("../config/spotless/.editorconfig").path) - licenseHeaderFile(rootProject.file("../config/spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)") - } -} - -detekt { - toolVersion = libs.versions.detekt.get() - config.setFrom(rootProject.file("../config/detekt/detekt.yml")) - buildUponDefaultConfig = true - allRules = false - source.setFrom(files("src/main/java", "src/main/kotlin")) -} - -gradlePlugin { - plugins { - register("meshtasticFlatpakOps") { - id = "meshtastic.flatpak-ops" - implementationClass = "org.meshtastic.flatpakops.FlatpakOpsPlugin" - } - } -} diff --git a/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt b/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt deleted file mode 100644 index 9ed009c38a..0000000000 --- a/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt +++ /dev/null @@ -1,254 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.flatpakops - -import groovy.json.JsonOutput -import groovy.json.JsonSlurper -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.internal.project.ProjectInternal -import org.gradle.internal.operations.BuildOperationDescriptor -import org.gradle.internal.operations.BuildOperationListener -import org.gradle.internal.operations.BuildOperationListenerManager -import org.gradle.internal.operations.OperationFinishEvent -import org.gradle.internal.operations.OperationIdentifier -import org.gradle.internal.operations.OperationProgressEvent -import org.gradle.internal.operations.OperationStartEvent -import org.gradle.internal.resource.ExternalResourceReadBuildOperationType -import java.io.File -import java.net.URI -import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap - -/** - * Captures every external resource URL Gradle reads via the internal BuildOperationListener API and emits a - * Flathub-compliant flatpak-sources.json at build finish. - * - * URL is authoritative (taken straight from the build op); the on-disk file is found via Gradle's files-2.1 layout, - * with a Module-Metadata-aware fallback for jars whose cache name differs from their URL name; SHA-256 is computed from - * that exact file. - * - * Internal APIs touched (acceptable trade-off; same path flatpak-gradle-generator uses): - * - org.gradle.internal.operations.BuildOperationListener / BuildOperationListenerManager - * - org.gradle.internal.resource.ExternalResourceReadBuildOperationType - * - org.gradle.api.internal.project.ProjectInternal (for .services) - */ -class FlatpakOpsPlugin : Plugin { - - override fun apply(target: Project) { - check(target == target.rootProject) { "meshtastic.flatpak-ops must be applied to the root project" } - - // Prefer the URL set populated by gradle/init-scripts/flatpak-ops.init.gradle.kts. - // The init script attaches its listener BEFORE any plugin/project resolution, so it - // captures bootstrap downloads (kotlin-dsl plugin marker, build-logic deps) that a - // listener registered here would miss. If the init script wasn't passed via -I, we - // fall back to a locally-attached listener — incomplete for build-logic deps but - // useful for developer debugging. No buildFinished cleanup here: this plugin loads in - // every normal build, and gradle.buildFinished is incompatible with the configuration - // cache. The fallback is rarely used and the per-build leak is benign. - @Suppress("UNCHECKED_CAST") - val capturedUrls: MutableSet = - (target.gradle.extensions.findByName("flatpakOpsCapturedUrls") as? MutableSet) - ?: ConcurrentHashMap.newKeySet().also { fallback -> - val manager = (target as ProjectInternal).services.get(BuildOperationListenerManager::class.java) - manager.addListener(OpListener(fallback)) - target.logger.warn( - "flatpak-ops: init script not loaded; build-logic bootstrap URLs will be missing. " + - "Pass -I gradle/init-scripts/flatpak-ops.init.gradle.kts for a complete manifest.", - ) - } - - val outputProvider = target.layout.buildDirectory.file("flatpak-ops-sources.json") - - target.tasks.register("captureFlatpakSources") { - group = "flatpak" - description = "Emit flatpak-sources.json from URLs captured via BuildOperationListener." - outputs.upToDateWhen { false } - // Order after the resolution-emitting tasks so we don't snapshot capturedUrls before - // their downloads happen. mustRunAfter is conditional — only the scheduled task enforces. - mustRunAfter(":desktopApp:assemble", ":desktopApp:packageUberJarForCurrentOS") - val proj = target - val urlsRef = capturedUrls - val outFile = outputProvider - doLast { writeSources(proj, urlsRef.toList(), outFile.get().asFile) } - } - } - - private class OpListener(private val urls: MutableSet) : BuildOperationListener { - override fun started(op: BuildOperationDescriptor, e: OperationStartEvent) = Unit - - override fun progress(id: OperationIdentifier, e: OperationProgressEvent) = Unit - - override fun finished(op: BuildOperationDescriptor, e: OperationFinishEvent) { - val details = op.details as? ExternalResourceReadBuildOperationType.Details ?: return - if (e.failure != null) return - // No host/scheme filtering here: non-Maven URLs (distribution zips, repo listings, etc.) - // naturally drop out in writeSources() when locateCacheFile() can't find them under - // files-2.1. Keeping this listener permissive avoids hardcoding repo allowlists. - urls.add(details.location) - } - } - - private fun writeSources(project: Project, urls: List, output: File) { - val filesRoot = File(project.gradle.gradleUserHomeDir, "caches/modules-2/files-2.1") - val entries: List> = - urls - .distinct() - .filterNot { url -> - // Exclude sources/javadoc jars: they're not needed for an offline build and - // inflate the manifest. Match on URL filename, not cache-relative path. - val tail = url.substringAfterLast('/') - tail.endsWith("-sources.jar") || tail.endsWith("-javadoc.jar") - } - .sorted() - .mapNotNull { url -> - val cacheFile = locateCacheFile(filesRoot, url) - if (cacheFile == null) { - project.logger.info("flatpak-ops: no cache file for {} (skipped)", url) - return@mapNotNull null - } - val rel = cacheFile.relativeTo(filesRoot).path.replace('\\', '/').split('/') - if (rel.size < CACHE_PATH_SEGMENTS) return@mapNotNull null - val group = rel[0] - val artifact = rel[1] - val version = rel[2] - val groupPath = group.replace('.', '/') - // dest-filename tracks the URL, not the cache: Gradle Module Metadata can declare - // files[].name ≠ files[].url, and the offline repo must serve at the URL path. - val urlFilename = URI(url).path.trimEnd('/').substringAfterLast('/') - val entry = - mutableMapOf( - "type" to "file", - "url" to url, - "sha256" to sha256(cacheFile), - "dest" to "offline-repository/$groupPath/$artifact/$version", - "dest-filename" to urlFilename, - ) - mirrorsFor(url).takeIf { it.isNotEmpty() }?.let { entry["mirror-urls"] = it } - entry - } - output.parentFile?.mkdirs() - output.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(entries))) - project.logger.lifecycle( - "flatpak-ops: captured {} URLs, emitted {} sources to {}", - urls.size, - entries.size, - output.absolutePath, - ) - } - - /** - * Map a Maven URL to its file in Gradle's files-2.1 cache (layout: - * `////`). Group is derived from the URL path — - * `(artifact, version, filename)` is not unique across groups (e.g. androidx.annotation:annotation:1.10.0 collides - * with org.jetbrains.compose.annotation-internal:annotation:1.10.0), so we probe every suffix of the leading path - * segments and the longest match wins (this also strips arbitrary repo prefixes like `/maven2/`, `/m2/`). - * - * Two-tier lookup: the fast path assumes the cache filename matches the URL filename (true for pom/module always, - * and most jars). For jars where Gradle Module Metadata renames the artifact locally (files[].name ≠ files[].url — - * e.g. com.mikepenz:aboutlibraries-compose-core-jvm publishes aboutlibraries-compose-core-jvm-14.2.1.jar but caches - * it as aboutlibraries-compose-jvm.jar) we parse the sibling .module file to resolve the real cache path. - */ - private fun locateCacheFile(filesRoot: File, url: String): File? { - val path = URI(url).path.trimEnd('/').split('/').filter { it.isNotEmpty() } - val tail = path.takeLast(URL_TRAILING_SEGMENTS) - if (!filesRoot.isDirectory || tail.size < URL_TRAILING_SEGMENTS) return null - val (artifact, version, filename) = tail - val groupCandidates = path.dropLast(URL_TRAILING_SEGMENTS) - // files-2.1 uses dot-joined group as a SINGLE directory, e.g. `androidx.annotation/`, not - // `androidx/annotation/` — so join with '.' here. - return groupCandidates.indices.firstNotNullOfOrNull { start -> - val groupDir = groupCandidates.drop(start).joinToString(".") - val versionDir = - File(filesRoot, "$groupDir/$artifact/$version").takeIf(File::isDirectory) - ?: return@firstNotNullOfOrNull null - val shaDirs = versionDir.listFiles { f -> f.isDirectory } ?: return@firstNotNullOfOrNull null - shaDirs.map { shaDir -> File(shaDir, filename) }.firstOrNull { it.isFile } - ?: filename.takeIf { it.endsWith(".jar") }?.let { resolveJarViaModuleMetadata(versionDir, shaDirs, it) } - } - } - - @Suppress("UNCHECKED_CAST") - private fun resolveJarViaModuleMetadata(versionDir: File, shaDirs: Array, urlFilename: String): File? { - val moduleFile = - shaDirs.firstNotNullOfOrNull { dir -> - dir.listFiles()?.firstOrNull { it.isFile && it.name.endsWith(".module") } - } - val entry = - moduleFile - ?.let { runCatching { JsonSlurper().parse(it) as? Map }.getOrNull() } - ?.let { it["variants"] as? List> } - ?.flatMap { (it["files"] as? List>).orEmpty() } - ?.firstOrNull { it["url"] == urlFilename } - val name = entry?.get("name") as? String - val sha1 = entry?.get("sha1") as? String - return if (name != null && sha1 != null) File(versionDir, "$sha1/$name").takeIf(File::isFile) else null - } - - /** - * Derive fallback mirror URLs from the primary URL's host. Only Maven Central has well-known public mirrors; for - * everything else (Google, JitPack, Gradle plugin portal, snapshot repos), we trust the primary URL since these - * hosts don't have stable mirrors anyway. The URL itself is authoritative — we just rewrite the host while - * preserving the path. - */ - private fun mirrorsFor(url: String): List { - val uri = runCatching { URI(url) }.getOrNull() - val host = uri?.host - val path = uri?.rawPath - return if (host != null && path != null && host in MAVEN_CENTRAL_HOSTS) { - MAVEN_CENTRAL_HOSTS.filter { it != host }.map { "https://$it$path" } + - "https://maven-central.storage-download.googleapis.com$path" - } else { - emptyList() - } - } - - private fun sha256(file: File): String { - val md = MessageDigest.getInstance("SHA-256") - file.inputStream().use { stream -> - val buf = ByteArray(BUFFER_SIZE) - while (true) { - val n = stream.read(buf) - if (n <= 0) break - md.update(buf, 0, n) - } - } - return hex(md.digest()) - } - - private fun hex(bytes: ByteArray): String { - val digits = "0123456789abcdef" - val chars = CharArray(bytes.size * HEX_CHARS_PER_BYTE) - for (i in bytes.indices) { - val v = bytes[i].toInt() and BYTE_MASK - chars[i * HEX_CHARS_PER_BYTE] = digits[v ushr NIBBLE_BITS] - chars[i * HEX_CHARS_PER_BYTE + 1] = digits[v and NIBBLE_MASK] - } - return String(chars) - } - - private companion object { - private val MAVEN_CENTRAL_HOSTS = listOf("repo.maven.apache.org", "repo1.maven.org") - private const val BUFFER_SIZE = 8192 - private const val CACHE_PATH_SEGMENTS = 5 - private const val URL_TRAILING_SEGMENTS = 3 - private const val HEX_CHARS_PER_BYTE = 2 - private const val BYTE_MASK = 0xFF - private const val NIBBLE_BITS = 4 - private const val NIBBLE_MASK = 0x0F - } -} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index 82089c17dd..8e074ab065 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -30,11 +30,6 @@ pluginManagement { } } -// Version mirrored in libs.versions.toml for Renovate tracking. -plugins { - id("com.gradle.develocity") version "4.4.2" -} - dependencyResolutionManagement { repositories { mavenCentral() @@ -55,10 +50,8 @@ dependencyResolutionManagement { } } -// Shared Develocity and Build Cache configuration -apply(from = "../gradle/develocity.settings.gradle") +// Build Cache configuration (HTTP remote cache + local) +apply(from = "../gradle/build-cache.settings.gradle") rootProject.name = "build-logic" include(":convention") -include(":flatpak-ops") - diff --git a/build.gradle.kts b/build.gradle.kts index f101ff4b8a..0cff19056c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,7 +40,19 @@ plugins { alias(libs.plugins.test.retry) apply false alias(libs.plugins.meshtastic.root) id("meshtastic.docs") - id("meshtastic.flatpak-ops") +} + +plugins.withId("org.meshtastic.flatpak.sources") { + extensions.configure { + outputFile.set(layout.buildDirectory.file("flatpak-sources.json")) + mustRunAfterTasks.set(listOf(":desktopApp:assemble", ":desktopApp:packageUberJarForCurrentOS")) + // Force-resolve platform-specific native artifacts not resolved on the generation host + targetPlatforms.set(setOf("linux-x64", "linux-arm64")) + platformDependencies.set(setOf( + "org.jetbrains.skiko:skiko-awt-runtime-{platform}:0.144.6", + "org.jetbrains.compose.desktop:desktop-jvm-{platform}:1.11.0", + )) + } } dependencies { diff --git a/gradle/build-cache.settings.gradle b/gradle/build-cache.settings.gradle new file mode 100644 index 0000000000..b2ce0c12e1 --- /dev/null +++ b/gradle/build-cache.settings.gradle @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +def getMeshProperty(String key) { + def env = System.getenv(key) + if (env) return env + + def currentDir = settingsDir + while (currentDir != null) { + def localFile = new File(currentDir, "local.properties") + if (localFile.exists()) { + def props = new Properties() + localFile.withInputStream { props.load(it) } + if (props.containsKey(key)) return props.getProperty(key) + } + def configFile = new File(currentDir, "config.properties") + if (configFile.exists()) { + def props = new Properties() + configFile.withInputStream { props.load(it) } + if (props.containsKey(key)) return props.getProperty(key) + } + currentDir = currentDir.parentFile + } + return null +} + +buildCache { + local { + enabled = true + } + remote(HttpBuildCache) { + // Some servers have issues with Expect: 100-continue and return 403. + useExpectContinue = false + def cacheUrl = getMeshProperty("GRADLE_CACHE_URL")?.trim() + def cacheUsername = getMeshProperty("GRADLE_CACHE_USERNAME")?.trim() + def cachePassword = getMeshProperty("GRADLE_CACHE_PASSWORD")?.trim() + + if (cacheUrl) { + def isLogic = settingsDir.name == "build-logic" + logger.lifecycle "Meshtastic ${isLogic ? 'Build Logic' : 'Build'}: Remote cache URL found." + + // Ensure trailing slash for cache servers + url = cacheUrl.endsWith("/") ? cacheUrl : "${cacheUrl}/" + + if (cacheUsername && cachePassword) { + credentials { + username = cacheUsername + password = cachePassword + } + } + + allowInsecureProtocol = true + allowUntrustedServer = true + + // Keep push enabled if credentials are provided. + // This naturally disables push for fork PRs which don't have access to secrets. + push = (cacheUsername && cachePassword) + + enabled = true + } else { + enabled = false + } + } +} diff --git a/gradle/develocity.settings.gradle b/gradle/develocity.settings.gradle deleted file mode 100644 index 24fdc2e356..0000000000 --- a/gradle/develocity.settings.gradle +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -def getMeshProperty(String key) { - def env = System.getenv(key) - if (env) return env - - def currentDir = settingsDir - while (currentDir != null) { - def localFile = new File(currentDir, "local.properties") - if (localFile.exists()) { - def props = new Properties() - localFile.withInputStream { props.load(it) } - if (props.containsKey(key)) return props.getProperty(key) - } - def configFile = new File(currentDir, "config.properties") - if (configFile.exists()) { - def props = new Properties() - configFile.withInputStream { props.load(it) } - if (props.containsKey(key)) return props.getProperty(key) - } - currentDir = currentDir.parentFile - } - return null -} - -develocity { - buildScan { - capture { - fileFingerprints = true - } - // Publish scans in CI for build failure debugging and performance profiling. - // Uses scans.gradle.com free tier (public scans). Disabled locally. - def isCi = System.getenv("CI") != null - publishing.onlyIf { isCi } - uploadInBackground = !isCi - termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use" - termsOfUseAgree = "yes" - } - buildCache { - local { - enabled = true - } - remote(HttpBuildCache) { - // Some servers have issues with Expect: 100-continue and return 403. - useExpectContinue = false - def cacheUrl = getMeshProperty("GRADLE_CACHE_URL")?.trim() - def cacheUsername = getMeshProperty("GRADLE_CACHE_USERNAME")?.trim() - def cachePassword = getMeshProperty("GRADLE_CACHE_PASSWORD")?.trim() - - if (cacheUrl) { - def isLogic = settingsDir.name == "build-logic" - logger.lifecycle "Meshtastic ${isLogic ? 'Build Logic' : 'Build'}: Remote cache URL found." - - // Ensure trailing slash for Develocity/GE servers - url = cacheUrl.endsWith("/") ? cacheUrl : "${cacheUrl}/" - - if (cacheUsername && cachePassword) { - credentials { - username = cacheUsername - password = cachePassword - } - } - - allowInsecureProtocol = true - allowUntrustedServer = true - - // Keep push enabled if credentials are provided. - // This naturally disables push for fork PRs which don't have access to secrets. - push = (cacheUsername && cachePassword) - - enabled = true - } else { - enabled = false - } - } - } -} diff --git a/gradle/init-scripts/flatpak-ops.init.gradle.kts b/gradle/init-scripts/flatpak-ops.init.gradle.kts deleted file mode 100644 index 738713c0f9..0000000000 --- a/gradle/init-scripts/flatpak-ops.init.gradle.kts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * Init script for meshtastic.flatpak-ops. Attaches a BuildOperationListener - * BEFORE any project or plugin resolution happens — which is necessary because - * the flatpak-ops plugin itself lives in build-logic, and any artifacts pulled - * to bootstrap build-logic (kotlin-dsl plugin marker, detekt, etc.) would be - * invisible to a listener registered later from a root-project plugin. - * - * Captured URLs are stored on `gradle.extensions` under the key below; the - * captureFlatpakSources task (registered by FlatpakOpsPlugin) reads them. - * - * Pass to Gradle via: - * ./gradlew -I gradle/init-scripts/flatpak-ops.init.gradle.kts ... - */ - -import org.gradle.api.internal.GradleInternal -import org.gradle.internal.operations.BuildOperationDescriptor -import org.gradle.internal.operations.BuildOperationListener -import org.gradle.internal.operations.BuildOperationListenerManager -import org.gradle.internal.operations.OperationFinishEvent -import org.gradle.internal.operations.OperationIdentifier -import org.gradle.internal.operations.OperationProgressEvent -import org.gradle.internal.operations.OperationStartEvent -import org.gradle.internal.resource.ExternalResourceReadBuildOperationType -import java.util.concurrent.ConcurrentHashMap - -val capturedUrls: MutableSet = ConcurrentHashMap.newKeySet() -gradle.extensions.add("flatpakOpsCapturedUrls", capturedUrls) - -val manager = - (gradle as GradleInternal).services.get(BuildOperationListenerManager::class.java) - -val listener = - object : BuildOperationListener { - override fun started(op: BuildOperationDescriptor, e: OperationStartEvent) = Unit - override fun progress(id: OperationIdentifier, e: OperationProgressEvent) = Unit - override fun finished(op: BuildOperationDescriptor, e: OperationFinishEvent) { - val details = op.details as? ExternalResourceReadBuildOperationType.Details ?: return - if (e.failure != null) return - capturedUrls.add(details.location) - } - } -manager.addListener(listener) - -// Detach at build finish so a long-lived daemon doesn't accumulate one listener per build. -// buildFinished is deprecated and breaks the configuration cache — fine here because this -// script is only loaded via `-I` in the verify flow, which uses --no-configuration-cache. -// Revisit (Flow API or AutoCloseable BuildService) when we drop Gradle 9 support. -@Suppress("DEPRECATION") -gradle.buildFinished { manager.removeListener(listener) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fe12c5d0fa..da8e522111 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -90,9 +90,8 @@ jmdns = "3.6.3" qrcode-kotlin = "4.5.0" takpacket-sdk = "0.3.0" -# Gradle Enterprise & Toolchains Plugins +# Gradle Plugins develocity = "4.4.2" -custom-user-data = "2.6.0" foojay-resolver = "1.0.0" @@ -325,7 +324,6 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } test-retry = { id = "org.gradle.test-retry", version.ref = "testRetry" } develocity = { id = "com.gradle.develocity", version.ref = "develocity" } -custom-user-data = { id = "com.gradle.common-custom-user-data-gradle-plugin", version.ref = "custom-user-data" } foojay-resolver = { id = "org.gradle.toolchains.foojay-resolver", version.ref = "foojay-resolver" } # Meshtastic diff --git a/scripts/verify-flatpak/verify.sh b/scripts/verify-flatpak/verify.sh index c8a6f49346..89ef6a3865 100755 --- a/scripts/verify-flatpak/verify.sh +++ b/scripts/verify-flatpak/verify.sh @@ -78,11 +78,10 @@ fi if [[ $SKIP_REGEN -eq 0 ]]; then step "Regenerating flatpak-sources.json via isolated Gradle home" rm -rf "$GRADLE_HOME_ISOLATED" - # Drive the same task the in-flatpak build runs so runtime-classpath deps (skiko, ktor-cio, - # datastore-proto, etc.) are resolved and captured — :assemble only triggers compileClasspath. + # The settings plugin (org.meshtastic.flatpak.sources.settings) captures URLs from + # build start — no init script or -I flag needed. (cd "$REPO_ROOT" && ./gradlew --no-build-cache --no-configuration-cache \ -Dgradle.user.home="$GRADLE_HOME_ISOLATED" \ - -I gradle/init-scripts/flatpak-ops.init.gradle.kts \ :desktopApp:packageUberJarForCurrentOS :captureFlatpakSources) cp "$REPO_ROOT/build/flatpak-ops-sources.json" "$SOURCES_JSON" elif [[ ! -f "$SOURCES_JSON" ]]; then diff --git a/settings.gradle.kts b/settings.gradle.kts index a84588b6da..28ce3c90e7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,9 +32,9 @@ pluginManagement { } plugins { - id("org.gradle.toolchains.foojay-resolver") version "1.0.0" id("com.gradle.develocity") version "4.4.2" - id("com.gradle.common-custom-user-data-gradle-plugin") version "2.6.0" + id("org.gradle.toolchains.foojay-resolver") version "1.0.0" + id("org.meshtastic.flatpak.sources.settings") version "0.1.1" } @Suppress("UnstableApiUsage") @@ -70,8 +70,22 @@ rootProject.name = "MeshtasticAndroid" // https://docs.gradle.org/current/userguide/declaring_dependencies.html#sec:type-safe-project-accessors enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") -// Shared Develocity and Build Cache configuration -apply(from = "gradle/develocity.settings.gradle") +// Build Cache configuration (HTTP remote cache + local) +apply(from = "gradle/build-cache.settings.gradle") + +// Build Scans — publish in CI only for debugging and performance profiling. +develocity { + buildScan { + capture { + fileFingerprints = true + } + val isCi = providers.environmentVariable("CI").isPresent + publishing.onlyIf { isCi } + uploadInBackground = !isCi + termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use" + termsOfUseAgree = "yes" + } +} @Suppress("UnstableApiUsage") toolchainManagement {