diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2c972b4cc..86b3c37a9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -370,13 +370,21 @@ jobs:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
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
+ # only sees ExternalResourceReadBuildOperation events for cache misses. With the
+ # shared cache, downloads would be skipped and the manifest would be (nearly) empty.
- name: Generate Flatpak Sources
run: >
- ./gradlew :desktopApp:assemble :generateFlatpakSourcesFromCache
- --no-configuration-cache
+ ./gradlew --no-build-cache --no-configuration-cache
+ -Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home
+ :desktopApp:assemble :captureFlatpakSources
+
+ - name: Stage manifest
+ run: cp build/flatpak-ops-sources.json flatpak-sources.json
- name: List Flatpak source files
- run: ls -R flatpak-sources.json
+ run: ls -l flatpak-sources.json
- name: Upload Flatpak source artifacts
if: always()
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index d656fa990..cfb7ffaa6 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -575,11 +575,16 @@ jobs:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: true
+ # Isolated Gradle user home — see explanation in release.yml.
- name: Generate Flatpak Sources
run: >
- ./gradlew :desktopApp:assemble :generateFlatpakSourcesFromCache
- --no-configuration-cache --refresh-dependencies
-
+ ./gradlew --no-build-cache --no-configuration-cache
+ -Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home
+ :desktopApp:assemble :captureFlatpakSources
+
+ - name: Stage manifest
+ run: cp build/flatpak-ops-sources.json flatpak-sources.json
+
- run: ls -lah flatpak-sources.json
- name: Upload Flatpak Sources
diff --git a/.gitignore b/.gitignore
index 3c0d2082f..b49126920 100644
--- a/.gitignore
+++ b/.gitignore
@@ -84,3 +84,4 @@ docs/_config_local.yml
flatpak-sources-*.json
flatpak-sources.json
offline-repository/
+.claude/
diff --git a/build-logic/flatpak/build.gradle.kts b/build-logic/flatpak-ops/build.gradle.kts
similarity index 88%
rename from build-logic/flatpak/build.gradle.kts
rename to build-logic/flatpak-ops/build.gradle.kts
index 2fc3d0c06..04ed7143a 100644
--- a/build-logic/flatpak/build.gradle.kts
+++ b/build-logic/flatpak-ops/build.gradle.kts
@@ -23,7 +23,7 @@ plugins {
alias(libs.plugins.detekt)
}
-group = "org.meshtastic.flatpak"
+group = "org.meshtastic.flatpakops"
java {
sourceCompatibility = JavaVersion.VERSION_21
@@ -33,7 +33,6 @@ java {
kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 } }
dependencies {
- // Allows type-safe accessors for libs in plugin build script
implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
detektPlugins(libs.detekt.formatting)
}
@@ -69,15 +68,14 @@ detekt {
config.setFrom(rootProject.file("../config/detekt/detekt.yml"))
buildUponDefaultConfig = true
allRules = false
- baseline = file("detekt-baseline.xml")
source.setFrom(files("src/main/java", "src/main/kotlin"))
}
gradlePlugin {
plugins {
- register("meshtasticFlatpak") {
- id = "meshtastic.flatpak"
- implementationClass = "org.meshtastic.flatpak.FlatpakPlugin"
+ 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
new file mode 100644
index 000000000..e63178969
--- /dev/null
+++ b/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt
@@ -0,0 +1,222 @@
+/*
+ * 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 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.
+ *
+ * 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.
+ *
+ * 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" }
+
+ val capturedUrls: MutableSet = ConcurrentHashMap.newKeySet()
+ val manager: BuildOperationListenerManager =
+ (target as ProjectInternal).services.get(BuildOperationListenerManager::class.java)
+
+ val listener = OpListener(capturedUrls)
+ manager.addListener(listener)
+
+ 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 }
+ // 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")
+ 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 {
+ 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