Files
amethyst/desktopApp/packaging/flatpak/flathub/tools/gradle-cache-to-sources.py
Vitor PamplonaandClaude Opus 5.5 062b25246c feat(flatpak): a Flathub manifest that builds Amethyst from source
Flathub doesn't accept the release manifest, which repackages CI's
prebuilt tree. desktopApp/packaging/flatpak/flathub/ builds the tag
offline instead:

- com.vitorpamplona.amethyst.Amethyst.yml compiles the tag tarball with
  `:desktopApp:createReleaseDistributable` on the JetBrains Runtime SDK
  21.0.11 (prebuilt, as Flathub ships it for com.prof18.feedflow), so the
  bundled runtime keeps JBR's Wayland toolkit. Sources: the tarball, JBR
  per arch, Gradle 9.8.0, the init script and gradle-sources.json.
- gradle-sources.json: the 2,185 Maven files the build resolves on
  x86_64 and aarch64 (1,566 Maven Central, 591 Google, 28 JitPack), each
  with its sha256, laid out as a local Maven repo.
- flathub-offline.init.gradle replaces every repository with that repo.
- tools/regenerate-gradle-sources.sh reruns the build online per arch in
  Docker on a fresh Gradle home and turns the module cache into the list
  (tools/gradle-cache-to-sources.py, which also maps Gradle module
  metadata's local file names to their published URLs).

Checked on aarch64: flatpak-builder builds it without network, the
installed Flatpak is 1.19.0 on runtime 26.08 with libawt_wlawt.so, and
`flatpak run` reaches the login screen. flatpak-builder-lint passes the
manifest and the metainfo; on the build it reports only the two
media-URL checks, which expect the absolute dl.flathub.org/media URLs
Flathub's builder writes where Debian's AppStream 1.0.5 writes relative
ones. Not checked: x86_64 through flatpak-builder, and Flathub's own
builder (it can't nest in Docker).

The flatpak README's Flathub section now tracks what is left: the AI
policy (these files are AI-written, the manifest says so, and the
submission must disclose it), domain verification, and submitting from
the next tag, whose metainfo has the screenshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:48:29 -04:00

133 lines
4.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Turn a Gradle user home's module cache into flatpak-builder sources.
Run the build once online with a fresh GRADLE_USER_HOME; the cache then holds
exactly what the build resolved. Every file becomes a `type: file` source laid
out as a Maven repository under `offline-repository/`, which the offline build
reads through an init script. URLs are found by asking the repositories
settings.gradle.kts declares, in its order, with a HEAD request; a file no
repository serves is an error, never a silent omission.
usage: gradle-cache-to-sources.py GRADLE_USER_HOME OUT.json [MORE_HOMES...]
"""
import concurrent.futures
import hashlib
import json
import os
import sys
import urllib.error
import urllib.request
GOOGLE = "https://dl.google.com/dl/android/maven2"
CENTRAL = "https://repo.maven.apache.org/maven2"
PORTAL = "https://plugins.gradle.org/m2"
JITPACK = "https://jitpack.io"
def repos_for(group):
google_first = group.startswith(("com.android", "com.google", "androidx"))
order = [GOOGLE, CENTRAL, PORTAL, JITPACK] if google_first else [CENTRAL, PORTAL, GOOGLE, JITPACK]
return order
def exists(url):
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "flatpak-sources-generator"})
for _ in range(3):
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status == 200
except urllib.error.HTTPError as e:
if e.code in (404, 403, 410):
return False
except Exception:
pass
return False
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def module_urls(vdir):
"""Gradle module metadata names a file one way (the cache keeps `name`) and
serves it under another (`url`, relative to the module's directory)."""
urls = {}
for sha1dir in os.listdir(vdir):
for name in os.listdir(os.path.join(vdir, sha1dir)):
if not name.endswith(".module"):
continue
with open(os.path.join(vdir, sha1dir, name)) as f:
meta = json.load(f)
for variant in meta.get("variants", []):
for entry in variant.get("files", []):
urls[entry["name"]] = entry["url"]
return urls
def collect(home, found):
root = os.path.join(home, "caches", "modules-2", "files-2.1")
for group in os.listdir(root):
for module in os.listdir(os.path.join(root, group)):
for version in os.listdir(os.path.join(root, group, module)):
vdir = os.path.join(root, group, module, version)
urls = module_urls(vdir)
for sha1dir in os.listdir(vdir):
for cached in os.listdir(os.path.join(vdir, sha1dir)):
path = os.path.join(vdir, sha1dir, cached)
# The repository (and so the offline one) has it under its url.
name = urls.get(cached, cached)
key = (group, module, version, name)
digest = sha256(path)
prev = found.get(key)
if prev and prev[1] != digest:
print(f"warning: two different {key}; keeping the first", file=sys.stderr)
continue
found.setdefault(key, (path, digest))
def resolve(item):
(group, module, version, name), (_, digest) = item
rel = f"{group.replace('.', '/')}/{module}/{version}/{name}"
for repo in repos_for(group):
url = f"{repo}/{rel}"
if exists(url):
return {
"type": "file",
"url": url,
"sha256": digest,
"dest": f"offline-repository/{group.replace('.', '/')}/{module}/{version}",
"dest-filename": name,
}
return {"missing": rel}
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
out = sys.argv[2]
found = {}
for home in [sys.argv[1]] + sys.argv[3:]:
collect(home, found)
print(f"{len(found)} files", file=sys.stderr)
with concurrent.futures.ThreadPoolExecutor(max_workers=32) as pool:
results = list(pool.map(resolve, sorted(found.items())))
missing = [r["missing"] for r in results if "missing" in r]
if missing:
print(f"{len(missing)} files no repository serves:", file=sys.stderr)
for m in missing:
print(" " + m, file=sys.stderr)
sys.exit(1)
results.sort(key=lambda r: (r["dest"], r["dest-filename"]))
with open(out, "w") as f:
json.dump(results, f, indent=2)
f.write("\n")
print(f"wrote {len(results)} sources to {out}", file=sys.stderr)
if __name__ == "__main__":
main()