From a4522f9f4db01c55dc55cce0e0ced13ac8ff0d1d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 26 May 2026 13:33:23 -0700 Subject: [PATCH] fix(flatpak): generate complete offline-buildable manifest for desktopApp (#5610) Co-authored-by: Claude Opus 4.7 --- .github/workflows/release.yml | 6 +- .github/workflows/reusable-check.yml | 5 +- .../meshtastic/flatpakops/FlatpakOpsPlugin.kt | 98 +++++++---- .../DatabaseManagerWithDbRetryTest.kt | 17 +- .../init-scripts/flatpak-ops.init.gradle.kts | 51 ++++++ scripts/verify-flatpak/README.md | 31 +++- scripts/verify-flatpak/desktop-offline.yaml | 16 +- scripts/verify-flatpak/verify.sh | 165 +++++++++++------- 8 files changed, 277 insertions(+), 112 deletions(-) create mode 100644 gradle/init-scripts/flatpak-ops.init.gradle.kts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86b3c37a9..48a27afb9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -374,11 +374,15 @@ jobs: # network — meshtastic.flatpak-ops 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 — + # so the runtime classpath is fully resolved (skiko, ktor-cio, kable-btleplug-ffi, … + # are runtime-only and would be missed by :assemble). - name: Generate Flatpak Sources run: > ./gradlew --no-build-cache --no-configuration-cache -Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home - :desktopApp:assemble :captureFlatpakSources + -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 diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index cfb7ffaa6..94ea28f9e 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -576,11 +576,14 @@ jobs: cache_read_only: true # Isolated Gradle user home — see explanation in release.yml. + # packageUberJarForCurrentOS is what the in-flatpak build invokes; it resolves the FULL + # runtime classpath. :assemble alone would miss runtime-only deps (skiko, ktor-cio, …). - name: Generate Flatpak Sources run: > ./gradlew --no-build-cache --no-configuration-cache -Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home - :desktopApp:assemble :captureFlatpakSources + -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 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 index e63178969..9ed009c38 100644 --- 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 @@ -17,6 +17,7 @@ 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 @@ -37,8 +38,9 @@ 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. * - * No heuristics: URL is authoritative (taken straight from the build op), the on-disk file is found via Gradle's - * documented files-2.1 layout, and SHA-256 is computed from that exact file. + * 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 @@ -50,12 +52,25 @@ class FlatpakOpsPlugin : Plugin { override fun apply(target: Project) { check(target == target.rootProject) { "meshtastic.flatpak-ops must be applied to the root project" } - val capturedUrls: MutableSet = ConcurrentHashMap.newKeySet() - val manager: BuildOperationListenerManager = - (target as ProjectInternal).services.get(BuildOperationListenerManager::class.java) - - val listener = OpListener(capturedUrls) - manager.addListener(listener) + // 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") @@ -63,21 +78,14 @@ class FlatpakOpsPlugin : Plugin { group = "flatpak" description = "Emit flatpak-sources.json from URLs captured via BuildOperationListener." outputs.upToDateWhen { false } - // Must run AFTER the task that triggers resolution. Without this, Gradle's scheduler - // may interleave/parallelize this task with :desktopApp:assemble, causing us to write - // the file before the downloads we want to capture have happened. - mustRunAfter(":desktopApp:assemble") + // 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) } } - // Listener is intentionally NOT removed on task completion; it stays attached until JVM - // exit. Removal is unsafe when our task races against other resolution-emitting tasks. - // Known limitation: in a long-lived Gradle daemon, capturedUrls accumulates across builds. - // We can't clear it at task start (that would erase what was captured during assemble). - // The CI workflow uses a fresh isolated GRADLE_USER_HOME, so this only affects local - // developer use — and re-emitting a superset of URLs is harmless. } private class OpListener(private val urls: MutableSet) : BuildOperationListener { @@ -118,15 +126,17 @@ class FlatpakOpsPlugin : Plugin { val group = rel[0] val artifact = rel[1] val version = rel[2] - val onDiskFilename = rel.last() 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 onDiskFilename, + "dest-filename" to urlFilename, ) mirrorsFor(url).takeIf { it.isNotEmpty() }?.let { entry["mirror-urls"] = it } entry @@ -142,12 +152,16 @@ class FlatpakOpsPlugin : Plugin { } /** - * Last three URL path segments are artifact/version/filename (Maven 2 spec; present in every Maven URL). Gradle's - * files-2.1 layout is `////`. The (artifact, version, - * filename) triple is NOT unique across groups (e.g. androidx.annotation:annotation:1.10.0.module collides with - * org.jetbrains.compose.annotation-internal:annotation:1.10.0.module), so the group MUST be derived from the URL - * path. We probe every suffix of the path's leading segments against the cache layout — the longest match wins - * (strips arbitrary repo prefixes like `/maven2/`, `/dl/android/maven2/`, `/m2/` without hardcoding them). + * 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() } @@ -155,18 +169,36 @@ class FlatpakOpsPlugin : Plugin { 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 URL segments with '.' here. + // 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(".") - File(filesRoot, "$groupDir/$artifact/$version") - .takeIf(File::isDirectory) - ?.listFiles { f -> f.isDirectory } - ?.map { shaDir -> File(shaDir, filename) } - ?.firstOrNull { it.isFile } + 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 diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerWithDbRetryTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerWithDbRetryTest.kt index d01540766..7b16c330d 100644 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerWithDbRetryTest.kt +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerWithDbRetryTest.kt @@ -77,11 +77,17 @@ class DatabaseManagerWithDbRetryTest { val result = async { manager.withDb { db -> visitedDbs += db - when (++attempts) { - 1 -> { - started.complete(Unit) - continueFirstAttempt.await() - } + if (++attempts == 1) { + started.complete(Unit) + continueFirstAttempt.await() + // Simulate the race the retry path is supposed to handle: oldDb's pool + // was closed between when we captured it and when we read from it. The + // previous version of this test triggered this by calling oldDb.close() + // and racing against the resumed read — which flapped in CI because + // Room's close() is not strictly ordered against in-flight reads. + // Throwing the representative exception directly makes the retry path + // deterministic; isDbClosedException matches "closed" + ("pool"|…). + error("Connection pool is closed") } db.nodeInfoDao().getMyNodeInfo().first()?.myNodeNum } @@ -93,7 +99,6 @@ class DatabaseManagerWithDbRetryTest { val newDb = manager.currentDb.value newDb.nodeInfoDao().setMyNodeInfo(newMyNodeInfo) - oldDb.close() continueFirstAttempt.complete(Unit) assertEquals(newMyNodeInfo.myNodeNum, result.await()) diff --git a/gradle/init-scripts/flatpak-ops.init.gradle.kts b/gradle/init-scripts/flatpak-ops.init.gradle.kts new file mode 100644 index 000000000..738713c0f --- /dev/null +++ b/gradle/init-scripts/flatpak-ops.init.gradle.kts @@ -0,0 +1,51 @@ +/* + * 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/scripts/verify-flatpak/README.md b/scripts/verify-flatpak/README.md index 46b593e5a..d2efdb99f 100644 --- a/scripts/verify-flatpak/README.md +++ b/scripts/verify-flatpak/README.md @@ -5,7 +5,7 @@ can validate `flatpak-sources.json` end-to-end without round-tripping through hi ## What it tests that our CI doesn't -Our CI (`:desktopApp:assemble :captureFlatpakSources`) only proves the manifest can be *generated*. +Our CI (`:desktopApp:packageUberJarForCurrentOS :captureFlatpakSources`) only proves the manifest can be *generated*. Vid's CI is where the manifest actually gets *consumed* by `flatpak-builder`. This script runs that step locally: @@ -28,15 +28,28 @@ instead of waiting on cross-repo CI. - Docker (Docker Desktop on macOS works — the container needs `--privileged` to use bubblewrap; that's enabled by default). - ~10 GB free disk for the SDK + Gradle cache. -- A populated Gradle cache (`./gradlew :desktopApp:assemble` must have run; the script - does this implicitly via `:captureFlatpakSources`). +- A populated Gradle cache (`./gradlew :desktopApp:packageUberJarForCurrentOS` must have + run; the script does this implicitly via `:captureFlatpakSources`). ## Usage ```bash -# Full offline build (~10–20 min the first time, faster after — Docker image is cached) +# Full offline build — Linux host required (~15–30 min first time) scripts/verify-flatpak/verify.sh +# URLs + sha256 verification only; skips the Gradle build phase. +# Works on macOS where nested bwrap fails under Docker Desktop's seccomp. +scripts/verify-flatpak/verify.sh --download-only + +# Reuse an already-generated flatpak-sources.json (don't re-run Gradle) +scripts/verify-flatpak/verify.sh --skip-regen + +# Tight iteration loop after a failed run: refresh overlay yaml + manifest +# only, then re-run flatpak-builder. Skips Gradle regen, vid-repo fetch, +# and Meshtastic-Android rsync. Use when you've just patched the YAML +# overlay or regenerated flatpak-sources.json by hand. +scripts/verify-flatpak/verify.sh --rebuild-only + # Cross-arch test via QEMU emulation (slower) scripts/verify-flatpak/verify.sh --arch aarch64 @@ -44,12 +57,20 @@ scripts/verify-flatpak/verify.sh --arch aarch64 scripts/verify-flatpak/verify.sh --shell ``` +### macOS limitation + +`flatpak-builder` runs the build phase inside `bwrap` (bubblewrap). Nested +bwrap fails inside Docker Desktop on macOS with +`prctl(PR_SET_SECCOMP) EINVAL`. The script refuses to run a full build on +macOS by default — pass `--download-only` to validate URLs + sha256s without +executing the Gradle build, or run the full script on a Linux host. + ## Interpreting failures | Symptom | Likely cause | | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | `Error downloading mirror: ... 404` on `repo.maven.apache.org` | URL captured by the listener was wrong or artifact moved. Check the source repo hosting it. | -| `sha256 mismatch` | Stale `flatpak-sources.json`; re-run `:desktopApp:assemble :captureFlatpakSources`. | +| `sha256 mismatch` | Stale `flatpak-sources.json`; re-run `:desktopApp:packageUberJarForCurrentOS :captureFlatpakSources`. | | Gradle: `Could not resolve all artifacts ... offline mode` | Missing dep in the manifest — usually a compiler plugin or BOM that wasn't downloaded during capture. | ## Files diff --git a/scripts/verify-flatpak/desktop-offline.yaml b/scripts/verify-flatpak/desktop-offline.yaml index 5511f201d..f4e1e3233 100644 --- a/scripts/verify-flatpak/desktop-offline.yaml +++ b/scripts/verify-flatpak/desktop-offline.yaml @@ -68,17 +68,19 @@ modules: - install -Dm644 -t /app/share/icons/hicolor/scalable/apps org.meshtastic.desktop.svg - install -Dm644 -t /app/share/applications org.meshtastic.desktop.desktop - install -Dm644 -t /app/share/metainfo org.meshtastic.desktop.metainfo.xml + # Redirect the Gradle wrapper to the bundled distribution (no network). + - sed -i 's|distributionUrl=.*|distributionUrl=gradle-all.zip|' gradle/wrapper/gradle-wrapper.properties - echo "org.gradle.java.installations.auto-detect=false" >> gradle.properties - echo "org.gradle.java.installations.auto-download=false" >> gradle.properties - echo "org.gradle.java.installations.paths=/usr/lib/sdk/openjdk21/jvm/openjdk-21,/usr/lib/sdk/openjdk17/jvm/openjdk-17" >> gradle.properties - > sed -i 's/^\(\s*\)vendor\.set(JvmVendorSpec\.JETBRAINS)/\1\/\/ vendor.set(JvmVendorSpec.JETBRAINS)/' - desktop/build.gradle.kts + desktopApp/build.gradle.kts # Force Gradle to resolve from the bundled offline-repository ONLY (true offline test). - - ./gradlew --offline :desktop:packageUberJarForCurrentOS + - ./gradlew --offline :desktopApp:packageUberJarForCurrentOS - > - JAR_FILE=$(find desktop/build/compose/jars/ -name "*.jar" -type f | head -1) + JAR_FILE=$(find desktopApp/build/compose/jars/ -name "*.jar" -type f | head -1) && install -Dm755 "$JAR_FILE" /app/lib/meshtastic-desktop.jar sources: - type: file @@ -90,8 +92,10 @@ modules: - type: dir path: meshtastic-android - type: file - url: https://services.gradle.org/distributions/gradle-9.4.1-bin.zip - sha256: 2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb + # Must match the version in gradle/wrapper/gradle-wrapper.properties. + # Bumping the wrapper? Update both the URL and sha256 here. + url: https://services.gradle.org/distributions/gradle-9.5.1-all.zip + sha256: c72fb9991f6025cbe337d52ba77e531b3faf62bdd3e348fe1ccee9f51c71adb0 dest: "gradle/wrapper" - dest-filename: "gradle-bin.zip" + dest-filename: "gradle-all.zip" - flatpak-sources.json diff --git a/scripts/verify-flatpak/verify.sh b/scripts/verify-flatpak/verify.sh index 30e888824..c8a6f4934 100755 --- a/scripts/verify-flatpak/verify.sh +++ b/scripts/verify-flatpak/verify.sh @@ -1,33 +1,50 @@ #!/usr/bin/env bash -# Local replica of vid's flatpak CI (vidplace7/org.meshtastic.desktop, .github/workflows/build-flatpak.yml) -# but flipped to true-offline mode: our flatpak-sources.json is included and --share=network is removed. +# Local replica of vid's flatpak CI (vidplace7/org.meshtastic.desktop, +# .github/workflows/build-flatpak.yml) but flipped to true-offline mode: our +# flatpak-sources.json is included and --share=network is removed from the +# build phase. # -# Goal: validate flatpak-sources.json without bugging vid to push & re-run his workflow. +# Goal: validate flatpak-sources.json end-to-end (download + verify sha256s + +# offline Gradle build) without bugging vid to push & re-run his workflow. # # Requirements: -# - Docker (Docker Desktop on macOS is fine; needs ~10GB free + ability to run --privileged) -# - This Meshtastic-Android checkout has produced flatpak-sources.json -# (run `./gradlew :desktopApp:assemble :captureFlatpakSources` first, or this script will do it) +# - Docker (Docker Desktop on macOS works for --download-only mode; full builds +# need a Linux host because flatpak-builder uses nested bwrap which fails +# under Docker Desktop's seccomp sandbox). +# - ~15GB free disk for the SDK + Gradle cache + builddir. # # Usage: -# scripts/verify-flatpak/verify.sh # full build, x86_64 -# scripts/verify-flatpak/verify.sh --arch aarch64 # cross-arch via QEMU emulation -# scripts/verify-flatpak/verify.sh --shell # drop into the container shell instead of building +# scripts/verify-flatpak/verify.sh # full offline build (Linux only) +# scripts/verify-flatpak/verify.sh --download-only # URLs+sha256 only (works on macOS) +# scripts/verify-flatpak/verify.sh --arch aarch64 # cross-arch via QEMU emulation +# scripts/verify-flatpak/verify.sh --shell # drop into builder container shell +# scripts/verify-flatpak/verify.sh --skip-regen # reuse flatpak-sources.json; still re-clone vid + re-rsync source +# scripts/verify-flatpak/verify.sh --rebuild-only # tight loop: refresh overlay+manifest only, then re-run flatpak-builder +# +# Iteration tip: after a build fails partway, fix the overlay YAML (or the +# Meshtastic-Android source) and re-run with --rebuild-only — Gradle regen, +# vid-repo fetch, and full source rsync are all skipped, so you get straight +# back to flatpak-builder in seconds. set -euo pipefail ARCH="x86_64" DROP_TO_SHELL=0 +DOWNLOAD_ONLY=0 +SKIP_REGEN=0 +REBUILD_ONLY=0 while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --shell) DROP_TO_SHELL=1; shift ;; - -h|--help) sed -n '2,17p' "$0"; exit 0 ;; + --download-only) DOWNLOAD_ONLY=1; shift ;; + --skip-regen) SKIP_REGEN=1; shift ;; + --rebuild-only) REBUILD_ONLY=1; SKIP_REGEN=1; shift ;; + -h|--help) sed -n '2,28p' "$0"; exit 0 ;; *) echo "Unknown arg: $1" >&2; exit 2 ;; esac done -# Map flatpak arch names to docker platform names case "$ARCH" in x86_64) DOCKER_PLATFORM="linux/amd64" ;; aarch64) DOCKER_PLATFORM="linux/arm64" ;; @@ -38,11 +55,12 @@ REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" WORK="$REPO_ROOT/build/flatpak-verify" OVERLAY="$REPO_ROOT/scripts/verify-flatpak/desktop-offline.yaml" SOURCES_JSON="$REPO_ROOT/flatpak-sources.json" +GRADLE_HOME_ISOLATED="$REPO_ROOT/build/flatpak-gradle-home" VID_REPO="https://github.com/vidplace7/org.meshtastic.desktop.git" -# Image provides flatpak + flatpak-builder. The freedesktop 25.08 runtime declared in -# the manifest is pulled from flathub at build time (no 25.08 image exists yet; 24.08 is -# fine as the builder host because the SDK used at compile time comes from flathub). +# bilelmoussaoui's image is what vid's CI uses; freedesktop-24.08 is the latest +# tag available. The 25.08 runtime declared in the manifest is pulled from +# flathub at build time inside the container. BUILDER_IMAGE="bilelmoussaoui/flatpak-github-actions:freedesktop-24.08" step() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; } @@ -50,67 +68,94 @@ fail() { printf '\033[1;31m!! %s\033[0m\n' "$*" >&2; exit 1; } command -v docker >/dev/null 2>&1 || fail "docker is required; install Docker Desktop or equivalent." -step "Ensuring flatpak-sources.json is fresh" -if [[ ! -f "$SOURCES_JSON" ]]; then - (cd "$REPO_ROOT" && ./gradlew --no-build-cache --no-configuration-cache :desktopApp:assemble :captureFlatpakSources) +# Refuse full-build mode on macOS — nested bwrap fails under Docker Desktop's +# seccomp and the user will spend 20 minutes finding out. They can override +# with --download-only. +if [[ "$(uname -s)" == "Darwin" && $DOWNLOAD_ONLY -eq 0 && $DROP_TO_SHELL -eq 0 ]]; then + fail "Full flatpak-builder runs require a Linux host (nested bwrap fails under Docker Desktop on macOS). Re-run with --download-only, or use --shell to poke around manually." +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. + (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 + fail "--skip-regen specified but $SOURCES_JSON does not exist." fi -step "Preparing workspace at $WORK" -mkdir -p "$WORK" -if [[ ! -d "$WORK/org.meshtastic.desktop/.git" ]]; then - git clone --depth 1 --recurse-submodules "$VID_REPO" "$WORK/org.meshtastic.desktop" +if [[ $REBUILD_ONLY -eq 1 ]]; then + [[ -d "$WORK/org.meshtastic.desktop/.git" ]] || \ + fail "--rebuild-only needs an existing workspace at $WORK; run without it once first." else - git -C "$WORK/org.meshtastic.desktop" fetch --depth 1 origin main - git -C "$WORK/org.meshtastic.desktop" reset --hard origin/main - git -C "$WORK/org.meshtastic.desktop" submodule update --init --recursive --depth 1 + step "Preparing workspace at $WORK" + mkdir -p "$WORK" + if [[ ! -d "$WORK/org.meshtastic.desktop/.git" ]]; then + git clone --depth 1 --recurse-submodules "$VID_REPO" "$WORK/org.meshtastic.desktop" + else + git -C "$WORK/org.meshtastic.desktop" fetch --depth 1 origin main + git -C "$WORK/org.meshtastic.desktop" reset --hard origin/main + git -C "$WORK/org.meshtastic.desktop" submodule update --init --recursive --depth 1 + fi fi +# Always refreshed — these are the iteration knobs: +# overlay yaml = the manifest we're testing +# flatpak-sources.json = the artifact we're validating step "Wiring overlay manifest + our flatpak-sources.json" cp "$OVERLAY" "$WORK/org.meshtastic.desktop/org.meshtastic.desktop.yaml" cp "$SOURCES_JSON" "$WORK/org.meshtastic.desktop/flatpak-sources.json" -# Materialize a clean copy of our checkout (excluding build outputs) for `type: dir`. -# flatpak-builder copies the whole tree — skip heavy/irrelevant paths. -step "Snapshotting Meshtastic-Android checkout (excluding build/, .gradle/)" -rsync -a --delete \ - --exclude='/build/' \ - --exclude='/.gradle/' \ - --exclude='*/build/' \ - --exclude='*/.gradle/' \ - --exclude='/.idea/' \ - --exclude='/local.properties' \ - "$REPO_ROOT/" "$WORK/org.meshtastic.desktop/meshtastic-android/" +if [[ $REBUILD_ONLY -eq 0 ]]; then + step "Snapshotting Meshtastic-Android checkout (excluding build/, .gradle/)" + rsync -a --delete \ + --exclude='/build/' \ + --exclude='/.gradle/' \ + --exclude='*/build/' \ + --exclude='*/.gradle/' \ + --exclude='/.idea/' \ + --exclude='/local.properties' \ + "$REPO_ROOT/" "$WORK/org.meshtastic.desktop/meshtastic-android/" +fi step "Pulling builder image: $BUILDER_IMAGE ($DOCKER_PLATFORM)" docker pull --platform "$DOCKER_PLATFORM" "$BUILDER_IMAGE" >/dev/null +DOCKER_RUN_ARGS=( + --rm + --privileged + -v "$WORK/org.meshtastic.desktop:/work" + -w /work + --platform "$DOCKER_PLATFORM" + --security-opt seccomp=unconfined +) + if [[ $DROP_TO_SHELL -eq 1 ]]; then step "Dropping into builder shell — flatpak-builder is on PATH" - exec docker run --rm -it --privileged \ - -v "$WORK/org.meshtastic.desktop:/work" \ - -w /work \ - --platform "$DOCKER_PLATFORM" \ - --security-opt seccomp=unconfined \ - "$BUILDER_IMAGE" bash + exec docker run -it "${DOCKER_RUN_ARGS[@]}" "$BUILDER_IMAGE" bash fi -step "Running flatpak-builder (arch=$ARCH)" -docker run --rm --privileged \ - -v "$WORK/org.meshtastic.desktop:/work" \ - -w /work \ - --platform "$DOCKER_PLATFORM" \ - --security-opt seccomp=unconfined \ - "$BUILDER_IMAGE" \ - bash -c "set -e - flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo - # --download-only verifies every source URL + sha256 and exits before the bwrap - # sandbox phase. We do this because nested bwrap fails inside Docker Desktop on - # macOS (prctl(PR_SET_SECCOMP) EINVAL). For full sandbox build, run on Linux directly - # — or rely on vid's GHA CI which uses bare ubuntu-24.04 runners. - flatpak-builder --user --repo=repo --install-deps-from=flathub --force-clean \ - --disable-rofiles-fuse --download-only \ - builddir org.meshtastic.desktop.yaml - echo - echo '=== All sources downloaded and sha256-verified successfully ===' - " +# Build flatpak-builder invocation. --download-only mode skips the bwrap-based +# build phase, which is the part that fails under Docker Desktop on macOS. +if [[ $DOWNLOAD_ONLY -eq 1 ]]; then + BUILDER_EXTRA_FLAGS="--download-only" + SUCCESS_MSG="All sources downloaded and sha256-verified successfully (URLs + hashes OK; Gradle build NOT exercised)" +else + BUILDER_EXTRA_FLAGS="" + SUCCESS_MSG="Full offline build succeeded — flatpak-sources.json is complete and self-sufficient" +fi + +step "Running flatpak-builder (arch=$ARCH, mode=$([[ $DOWNLOAD_ONLY -eq 1 ]] && echo download-only || echo full-build))" +docker run "${DOCKER_RUN_ARGS[@]}" "$BUILDER_IMAGE" bash -c "set -e + flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + flatpak-builder --user --repo=repo --install-deps-from=flathub --force-clean \ + --disable-rofiles-fuse $BUILDER_EXTRA_FLAGS \ + builddir org.meshtastic.desktop.yaml + echo + echo '=== $SUCCESS_MSG ===' +"