From 2fc098119514fc7ba18f4722877931dda9ecd82b Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 30 Aug 2026 03:53:38 +0200 Subject: [PATCH] Somewhere for the app to look for a newer version of itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two static files behind nginx and a script that writes them. No authentication: there is no account to check against, and the APK carries no secret — the credential in that app is the addon URL, which lives in the device keystore. The APKs are committed rather than stored elsewhere. At ~34MB and a few releases a year that beats standing up artifact storage for one app, and the history is disposable because only the newest artifact is ever fetched. release.py reads the version out of amber-adult's pubspec instead of taking it as an argument. A manifest advertising a build number the APK does not carry is an update the device installs and is then offered again, every launch, forever. Co-Authored-By: Claude Opus 5 --- .dockerignore | 3 +++ Dockerfile | 4 ++++ README.md | 40 +++++++++++++++++++++++++++++++ nginx.conf | 29 ++++++++++++++++++++++ release.py | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 nginx.conf create mode 100755 release.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8022308 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +release.py +README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3e03316 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +# Static files, nothing more: a manifest and the APK it points at. +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY public/ /usr/share/nginx/html/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5006dc --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# media-updates + +What the `amber-adult` app checks at launch to find a newer build of itself. + +Two files behind nginx: `manifest.json`, and the APK it names. No +authentication, deliberately — there is no account to check against and the +APK holds no secret. The credential in that app is the addon URL, which lives +in the device keystore and never leaves it. + +## Publishing a release + +``` +./release.py ../amber-adult/build/app/outputs/flutter-apk/app-release.apk "what changed" +``` + +It reads the version and build number out of `amber-adult/pubspec.yaml`, copies +the APK in under a versioned name, writes `manifest.json`, and commits. Push, +and Coolify redeploys. + +**The APKs are committed.** At ~34MB and a handful of releases a year that is +the cheaper trade against standing up artifact storage for one app; if the +repository ever gets uncomfortable, the history is disposable — only the +newest artifact is ever fetched. + +## The manifest + +```json +{ + "version": "0.1.0", + "buildNumber": 1, + "notes": "…", + "sha256": "…64 hex…", + "size": 34210000, + "url": "https://media-updates.petruzalekr.cz/media-1.apk" +} +``` + +The client refuses anything it cannot act on: an equal or older `buildNumber`, +a missing `url`, or a `sha256` that is not 64 characters. The download is +checked against that digest before it reaches the installer. diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..bd311b4 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + root /usr/share/nginx/html; + + # Nginx's bundled mime.types does not name the APK type on every build, and + # a wrong Content-Type is enough for a download manager to save an HTML + # error page under the right filename. Say it explicitly. + types { + application/json json; + application/vnd.android.package-archive apk; + } + default_type application/octet-stream; + + # The manifest changes every release; the APKs never do, because a new + # release is a new filename. Cache accordingly. + location = /manifest.json { + add_header Cache-Control "no-cache"; + } + location ~ \.apk$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Nothing here is a secret, but nothing here is a directory listing either. + autoindex off; + + location / { + try_files $uri =404; + } +} diff --git a/release.py b/release.py new file mode 100755 index 0000000..863594d --- /dev/null +++ b/release.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Publish an APK: copy it in, write the manifest, commit. + +Reads the version and build number out of amber-adult's pubspec so the two +cannot drift — a manifest advertising a build number the APK does not carry is +an update the device installs and is then offered again on every launch. +""" +import hashlib +import json +import pathlib +import re +import shutil +import subprocess +import sys + +HERE = pathlib.Path(__file__).resolve().parent +PUBSPEC = HERE.parent / "amber-adult" / "pubspec.yaml" +BASE_URL = "https://media-updates.petruzalekr.cz" + + +def pubspec_version() -> tuple[str, int]: + m = re.search(r"^version:\s*([0-9.]+)\+([0-9]+)\s*$", + PUBSPEC.read_text(encoding="utf-8"), re.M) + if not m: + sys.exit(f"no version line in {PUBSPEC}") + return m.group(1), int(m.group(2)) + + +def main() -> None: + if len(sys.argv) < 2: + sys.exit("usage: release.py [notes]") + apk = pathlib.Path(sys.argv[1]).resolve() + notes = sys.argv[2] if len(sys.argv) > 2 else "" + if not apk.is_file(): + sys.exit(f"no such APK: {apk}") + + version, build = pubspec_version() + public = HERE / "public" + public.mkdir(exist_ok=True) + name = f"media-{build}.apk" + dest = public / name + shutil.copy2(apk, dest) + + digest = hashlib.sha256(dest.read_bytes()).hexdigest() + manifest = { + "version": version, + "buildNumber": build, + "notes": notes, + "sha256": digest, + "size": dest.stat().st_size, + "url": f"{BASE_URL}/{name}", + } + (public / "manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + subprocess.run(["git", "add", "-A"], cwd=HERE, check=True) + subprocess.run( + ["git", "commit", "-m", f"Release {version}+{build}"], + cwd=HERE, check=True) + print(f"staged {name} ({dest.stat().st_size // 1024 // 1024} MB), " + f"version {version}+{build}") + print("push to deploy") + + +if __name__ == "__main__": + main()