diff --git a/docs/android-distribution.md b/docs/android-distribution.md index 506ea96..66c0505 100644 --- a/docs/android-distribution.md +++ b/docs/android-distribution.md @@ -36,6 +36,10 @@ pinning. If you want a specific build — testing something, or holding back — drop it in `/var/thoughtsync/client/` and it wins over the image's copy. +That directory is shared with the desktop clients the server hands out, and +**precedence is decided per platform**: dropping in an APK overrides the baked APK +and leaves every other client alone. It is one directory, not one choice. + Two files, both required: | File | What it is | diff --git a/src/thoughtsync/app.py b/src/thoughtsync/app.py index 0809c59..bd2ed75 100644 --- a/src/thoughtsync/app.py +++ b/src/thoughtsync/app.py @@ -204,9 +204,10 @@ def create_app() -> Quart: # linking — while it still has no token and possibly no account — to decide # whether it can talk to this server, and which optional features to offer. data.update(protocol_advertisement()) - # Which Android client this server can hand out, if any. Absent rather than - # null when it has none, so the web UI hides the download instead of - # offering a button that 404s. + # Which CLIENTS this server can hand out, if any — the whole set under + # `clients`, plus the older `android_client` key that phones in the field + # still read. Absent rather than null when it has none, so the web UI hides + # a download instead of offering a button that 404s. data.update(client_advertisement()) return jsonify(data) diff --git a/src/thoughtsync/client_dist.py b/src/thoughtsync/client_dist.py index 7d3239d..a75ede4 100644 --- a/src/thoughtsync/client_dist.py +++ b/src/thoughtsync/client_dist.py @@ -1,4 +1,4 @@ -"""The server hands out the Android client it is in step with. +"""The server hands out the clients it is in step with. ## Why the server, and not a release page @@ -11,43 +11,69 @@ It also keeps the two in step by construction. Client and server already negotiate a sync protocol version before linking, so a server that also serves the client cannot hand out a phone it is unable to talk to. -## Where the file comes from +That argument was never Android-specific, which is why this module now serves a +TABLE of platforms rather than the one it was written for. -Two places, checked in that order: +## Where the files come from + +Two places, checked in that order, per platform: 1. `DATA_DIR/client/` — the mounted volume that already holds attachments. An operator who wants a SPECIFIC build drops it there and it wins. 2. the copy baked into the image at build time — CI fetches the newest published - Android build into every image, so `:dev`, `:latest` and `:` all - carry a client and `docker compose pull` delivers a new one with nothing - copied by hand. + build of each client into every image, so `:dev` and `:latest` both carry a + full set and `docker compose pull` delivers new ones with nothing copied by + hand. The precedence is the point: the image is the default, and a person who wants to override it should not have to fight it. The baked copy sits inside the package rather than under DATA_DIR because DATA_DIR is a volume mount, and anything the image wrote there would be hidden the moment one is attached. +**Precedence is decided per platform, not for the set.** A drop-in `.deb` does +not shadow the baked APK. The alternative — first directory holding anything +wins — would mean replacing one client silently retracts the other four. + +## What a platform needs on disk + Two files, and both must be present: -- `thoughtsync.apk` — the client -- `thoughtsync-android.json` — `{version_name, version_code, size, sha256}` +- the artifact, at a FIXED name (`thoughtsync.deb`, not + `ThoughtSync_2026.08.30.0307_amd64.deb`) +- its sidecar, `{version_name, version_code, size, sha256}` -The sidecar exists because an APK's version lives in a binary AXML manifest that -Python cannot read without the Android build tools. CI writes it beside the APK -at publish time, where the real values are already known. +**Fixed names, and the version only in the sidecar.** A version-stamped filename +would force this module to glob, and a glob over a directory an operator can drop +files into is how you serve the older of two builds — `write-manifest.sh` carries +a comment about exactly that, from the time it advertised a new version while +pointing at an old binary. + +The sidecar exists because a version is not reliably readable from the artifact: +an APK keeps it in a binary AXML manifest Python cannot parse without the Android +build tools, and a `.deb` or an AppImage would each need a different unpacker. CI +writes the sidecar where the real value is already known. + +The AppImage needs a THIRD file, `thoughtsync.AppImage.sig`. That is the minisign +signature the desktop updater verifies before replacing the running binary, and a +bundle that cannot be verified cannot be offered as an update — so a missing +signature makes the AppImage absent rather than merely unsigned. ## Absence is normal -A server with no APK advertises nothing, and the web UI hides the download -rather than offering a button that 404s. Same for a mismatched pair: if the -sidecar's recorded size does not match the file on disk, the two did not arrive -together and the server says it has nothing rather than serving one build while -describing another. +A server with no client for a platform advertises none, and the web UI hides that +download rather than offering a button that 404s. Same for a mismatched pair: if +the sidecar's recorded size does not match the file on disk, the two did not +arrive together, and the server says it has nothing rather than serving one build +while describing another. + +This is the ONLY state available before a platform's first build has ever +published, so it is an ordinary answer and never an error. """ from __future__ import annotations import json +from dataclasses import dataclass from pathlib import Path from quart import Blueprint, jsonify, send_from_directory @@ -55,10 +81,90 @@ from quart import Blueprint, jsonify, send_from_directory from .auth import login_required from .config import Config -APK_NAME = "thoughtsync.apk" -MANIFEST_NAME = "thoughtsync-android.json" -DOWNLOAD_PATH = "/api/client/android/download" -APK_MIMETYPE = "application/vnd.android.package-archive" + +@dataclass(frozen=True) +class Platform: + """One installable client, and where its files sit.""" + + id: str + # What a person calls it. Named for the DISTRO rather than the package format + # ("Debian / Ubuntu", not ".deb") — someone knows which system they run and + # does not necessarily know which packaging it uses. + label: str + artifact: str + sidecar: str + mimetype: str + # An updater-verifiable bundle: `.sig` must be present too, and its + # contents are published with the metadata. Only the AppImage, because it is + # the only bundle that can replace itself in place — a package-manager install + # cannot, by design (see the desktop's update.rs). + signed: bool = False + + @property + def signature(self) -> str: + return f"{self.artifact}.sig" + + @property + def download_path(self) -> str: + return f"/api/client/{self.id}/download" + + +# ONE definition of what this server can hand out. Every route, the /api/config +# advertisement and the tests all read this table; adding a platform is adding a +# row. +PLATFORMS: tuple[Platform, ...] = ( + Platform( + id="android", + label="Android", + # UNCHANGED, and it must stay unchanged: the Android lane publishes these + # exact names, CI bakes them in under them, and clients in the field poll + # `/api/client/android`. Renaming them to match the pattern below would + # buy tidiness and strand every installed phone. + artifact="thoughtsync.apk", + sidecar="thoughtsync-android.json", + mimetype="application/vnd.android.package-archive", + ), + Platform( + id="linux-deb", + label="Debian / Ubuntu", + artifact="thoughtsync.deb", + sidecar="thoughtsync-linux-deb.json", + mimetype="application/vnd.debian.binary-package", + ), + Platform( + id="linux-pacman", + label="Arch / CachyOS", + artifact="thoughtsync.pkg.tar.zst", + sidecar="thoughtsync-linux-pacman.json", + mimetype="application/zstd", + ), + Platform( + id="linux-appimage", + label="Other Linux (AppImage)", + artifact="thoughtsync.AppImage", + sidecar="thoughtsync-linux-appimage.json", + # No registered type for an AppImage, and guessing one buys nothing: it is + # served as an attachment either way, and octet-stream is the answer that + # cannot be wrong. + mimetype="application/octet-stream", + signed=True, + ), + Platform( + id="windows", + label="Windows", + artifact="thoughtsync-setup.exe", + sidecar="thoughtsync-windows.json", + mimetype="application/vnd.microsoft.portable-executable", + ), +) + +BY_ID: dict[str, Platform] = {p.id: p for p in PLATFORMS} + +# The Android names, still importable under their old spellings because docs and +# the CI lane refer to them. Derived from the table rather than restated, so the +# two cannot drift. +APK_NAME = BY_ID["android"].artifact +MANIFEST_NAME = BY_ID["android"].sidecar # The copy CI bakes into the image. Inside the package, NOT under DATA_DIR: that # is a volume mount, and a file the image wrote there would vanish behind it. @@ -67,17 +173,17 @@ BAKED_ROOT = Path(__file__).resolve().parent / "client" bp = Blueprint("client_dist", __name__) -def _read(root: Path) -> dict | None: - """The build in one directory, or None. +def _read(root: Path, platform: Platform) -> dict | None: + """One platform's build in one directory, or None. - Never raises. A missing directory, an unreadable sidecar, malformed JSON and a - sidecar that describes a different file are all the same answer to the only - question being asked — "is there a client here I can honestly offer?" — and - that answer is no. + Never raises. A missing directory, an unreadable sidecar, malformed JSON, a + sidecar that describes a different file and — for a signed bundle — a missing + signature are all the same answer to the only question being asked, "is there + a client here I can honestly offer?", and that answer is no. """ try: - size = (root / APK_NAME).stat().st_size - meta = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8")) + size = (root / platform.artifact).stat().st_size + meta = json.loads((root / platform.sidecar).read_text(encoding="utf-8")) version = str(meta["version_name"]) code = int(meta["version_code"]) recorded = int(meta["size"]) @@ -87,83 +193,163 @@ def _read(root: Path) -> dict | None: # The pair has to describe one build. A sidecar left behind by a previous # release would otherwise advertise a version this server cannot serve, and the - # phone would download something other than what it was promised. + # client would download something other than what it was promised. if recorded != size: return None - return { + release = { + "platform": platform.id, + "label": platform.label, "version": version, - # What Android actually compares. `version` is for people; a name is a - # string and sorts like one, which is not how "is this newer" works. + # What a comparator reads. `version` is for people; a name is a string and + # sorts like one, which is not how "is this newer" works. "version_code": code, "size": size, # Computed by CI over the same bytes it uploaded, so a client can tell a - # truncated download from a complete one BEFORE handing it to the - # installer. Not a trust anchor — the signature is that. + # truncated download from a complete one BEFORE handing it to an installer. + # Not a trust anchor — the signature is that. "sha256": digest, - "url": DOWNLOAD_PATH, + # A PATH, never an absolute URL: the client joins it to the base it is + # already linked to, so a compromised or misconfigured server cannot + # redirect the download somewhere else. `core/src/sync/client.rs` relies on + # this and says so. + "url": platform.download_path, } + if platform.signed: + # The signature travels WITH the metadata rather than behind its own route. + # It is ~100 bytes, it is public wherever these bundles are published, and + # the updater needs the version and the signature in the same breath — one + # request that cannot return a signature belonging to a different build. + try: + sig = (root / platform.signature).read_text(encoding="utf-8").strip() + except OSError: + return None + if not sig: + return None + release["signature"] = sig -def _resolve() -> tuple[Path, dict] | None: - """Which directory this server serves from, and what is in it. + return release + + +def _resolve(platform: Platform) -> tuple[Path, dict] | None: + """Which directory this server serves a platform from, and what is in it. The operator's drop-in beats the baked copy — someone who deliberately put a - build on the volume wants that build, not whatever the image happened to ship - with. A directory holding a broken or half-copied pair does NOT shadow the - image: it simply is not a client, so the search moves on. + build on the volume wants that build, older or not. A directory holding a + broken or half-copied pair does NOT shadow the image: it simply is not a + client, so the search moves on. """ for root in (Path(Config.client_root()), BAKED_ROOT): - release = _read(root) + release = _read(root, platform) if release is not None: return root, release return None -def android_release() -> dict | None: - """What Android build this server holds, or None if it holds none.""" - resolved = _resolve() +def release(platform_id: str) -> dict | None: + """What build of one client this server holds, or None if it holds none.""" + platform = BY_ID.get(platform_id) + if platform is None: + return None + resolved = _resolve(platform) return resolved[1] if resolved else None -def advertisement() -> dict: - """The `/api/config` fragment describing this server's Android client. +def releases() -> dict[str, dict]: + """Every client this server can hand out, keyed by platform id. - An empty dict when there is none, so the key is ABSENT rather than null — a - client testing for the key gets one unambiguous answer instead of having to - distinguish "no client" from "old server that never had this field". + Platforms it holds nothing for are ABSENT rather than present-and-null, so a + caller can test for the key instead of distinguishing "no build" from "a + server that never had this platform". """ - release = android_release() - return {"android_client": release} if release else {} + found = {p.id: _resolve(p) for p in PLATFORMS} + return {pid: r[1] for pid, r in found.items() if r is not None} -@bp.get("/api/client/android") -async def android_metadata(): - """Version and digest without the 55 MiB. What an updater polls.""" - release = android_release() - if release is None: - return jsonify({"error": "this server has no Android client"}), 404 - return jsonify(release) +def android_release() -> dict | None: + """What Android build this server holds, or None. + + Kept as its own name because the back-compatible `/api/config` key below is + about Android specifically, and because saying so reads better than + `release("android")` at the two call sites that mean the phone. + """ + return release("android") -@bp.get(DOWNLOAD_PATH) +def advertisement() -> dict: + """The `/api/config` fragment describing this server's clients. + + Two keys, deliberately, and the older one is not deprecated here: + + `clients` is the whole table, which is what the web UI renders the downloads + section from. + + `android_client` is what phones in the field already read. It costs one + duplicated dict to not strand every installed Android client, and retiring it + is a later decision made when nothing polls it — not a tidy-up done in the + change that introduces its replacement. + + Both are ABSENT rather than null when empty, so a client testing for a key gets + one unambiguous answer instead of having to distinguish "no client" from "an + old server that never had this field". + """ + data: dict = {} + found = releases() + if found: + data["clients"] = found + if "android" in found: + data["android_client"] = found["android"] + return data + + +@bp.get("/api/client") +async def client_index(): + """Everything this server holds, in one request. + + The downloads UI needs all five to decide what to lead with, and five requests + to answer one question is five chances to render half a page. + """ + return jsonify({"clients": releases()}) + + +@bp.get("/api/client/") +async def client_metadata(platform_id: str): + """Version and digest without the payload. What an updater polls. + + Public, because a client has to be able to ask "is there something newer?" + cheaply — before it has a token, in the case of a first pairing. + + An unknown platform and a platform with no build both 404. They are the same + answer to the caller ("not here"), and distinguishing them would only tell an + unauthenticated stranger which platforms this build of the server knows about. + """ + found = release(platform_id) + if found is None: + return jsonify({"error": f"this server has no {platform_id} client"}), 404 + return jsonify(found) + + +@bp.get("/api/client//download") @login_required -async def android_download(): - """The APK itself. +async def client_download(platform_id: str): + """The bytes themselves. Authenticated — by session cookie from a browser, or by device bearer token from a client updating itself; `login_required` accepts either. The metadata - above is public because a client has to be able to ask "is there something - newer?" cheaply, but the bytes are not for anyone who can reach the port. + above is public; the bytes are not for anyone who can reach the port. """ - resolved = _resolve() + platform = BY_ID.get(platform_id) + if platform is None: + return jsonify({"error": f"unknown client platform '{platform_id}'"}), 404 + resolved = _resolve(platform) if resolved is None: - return jsonify({"error": "this server has no Android client"}), 404 - # From the SAME directory the advertisement came from, or a drop-in appearing - # between the two calls would serve bytes the metadata does not describe. + return jsonify({"error": f"this server has no {platform_id} client"}), 404 + # From the SAME directory the metadata came from, or a drop-in appearing between + # the two calls would serve bytes the metadata does not describe. root, _ = resolved - response = await send_from_directory(root, APK_NAME, mimetype=APK_MIMETYPE) + response = await send_from_directory(root, platform.artifact, mimetype=platform.mimetype) # Without this some browsers try to render it, and Android's download handler # wants a filename to hand to the package installer. - response.headers["Content-Disposition"] = f'attachment; filename="{APK_NAME}"' + response.headers["Content-Disposition"] = f'attachment; filename="{platform.artifact}"' return response diff --git a/src/thoughtsync/config.py b/src/thoughtsync/config.py index 17f787f..690e177 100644 --- a/src/thoughtsync/config.py +++ b/src/thoughtsync/config.py @@ -35,12 +35,15 @@ class Config: @classmethod def client_root(cls) -> Path: - """Where the Android APK this server hands out lives. + """Where an operator DROPS IN clients for this server to hand out. - Under DATA_DIR rather than baked into the image: the APK is ~55 MiB and an - install that never touches Android should not carry it. Being on the same - mounted volume as uploads also means an operator drops a build there once - and container recreation does not lose it. See client_dist.py. + One directory for every platform; `client_dist.py` picks files out of it by + name. Under DATA_DIR because it is a mounted volume: a build placed here + survives container recreation, and it beats the copy baked into the image, + which is the whole point of the directory existing. + + Empty is the ordinary case — the image ships its own set and most operators + never touch this. """ return Path(cls.DATA_DIR) / "client" diff --git a/tests/test_client_dist.py b/tests/test_client_dist.py index 70dac52..5ef3eb2 100644 --- a/tests/test_client_dist.py +++ b/tests/test_client_dist.py @@ -5,7 +5,16 @@ import pytest from thoughtsync import client_dist from thoughtsync.app import create_app -from thoughtsync.client_dist import APK_NAME, MANIFEST_NAME, advertisement, android_release +from thoughtsync.client_dist import ( + APK_NAME, + BY_ID, + MANIFEST_NAME, + PLATFORMS, + advertisement, + android_release, + release, + releases, +) from thoughtsync.config import Config # DB-free, like the rest of this suite — the test lane runs no Postgres. That is @@ -13,11 +22,17 @@ from thoughtsync.config import Config # `/api/config`: the route is a one-line merge of this dict into a payload whose # other half needs a database, and testing it here tests the part that can be wrong. # -# The two routes below ARE exercised, because neither opens a session: the metadata -# route only stats files, and the download's 401 is returned before any token -# lookup. +# The routes below ARE exercised, because none opens a session: the metadata route +# only stats files, and the download's 401 is returned before any token lookup. -PAYLOAD = b"not really an apk, but the server only ever stats it" +PAYLOAD = b"not really a client, but the server only ever stats it" + +# Every test that is about the MECHANISM rather than about one platform runs +# against all of them. The bugs this module can have — a sidecar describing a +# different build, a half-finished copy shadowing a good one — are not +# platform-specific, and a suite that only ever exercised Android is how the other +# four would ship untested. +ALL_IDS = [p.id for p in PLATFORMS] @pytest.fixture(autouse=True) @@ -26,7 +41,7 @@ def _empty_baked_client(tmp_path, monkeypatch): In a source checkout `src/thoughtsync/client/` does not exist, so these tests would pass anyway — but only by accident of where they are run. A built image - has a real APK there, and a test that silently depends on which tree it is in + has real clients there, and a test that silently depends on which tree it is in is one that will eventually lie. """ monkeypatch.setattr(client_dist, "BAKED_ROOT", tmp_path / "baked") @@ -38,109 +53,225 @@ def app(): return create_app() -def place_client(payload: bytes = PAYLOAD, root: Path | None = None, **overrides) -> dict: - """Put a client + sidecar where the server looks. Overrides corrupt the pair.""" - root = root if root is not None else Config.client_root() +def place( + platform_id: str = "android", + payload: bytes = PAYLOAD, + root: Path | None = None, + signature: str | None = "a signature", + **overrides, +) -> dict: + """Put one platform's client + sidecar where the server looks. + + Overrides corrupt the pair. `signature=None` withholds the `.sig` a signed + bundle needs, which is its own failure mode rather than a variant of the others. + """ + platform = BY_ID[platform_id] + root = root if root is not None else Path(Config.client_root()) root.mkdir(parents=True, exist_ok=True) - (root / APK_NAME).write_bytes(payload) + (root / platform.artifact).write_bytes(payload) meta = { - "version_name": "0.1.216", - "version_code": 216, + "version_name": "2026.08.30.0307", + "version_code": 3503708, "size": len(payload), "sha256": "ab" * 32, } meta.update(overrides) - (root / MANIFEST_NAME).write_text(json.dumps(meta), encoding="utf-8") + (root / platform.sidecar).write_text(json.dumps(meta), encoding="utf-8") + if platform.signed and signature is not None: + (root / platform.signature).write_text(signature, encoding="utf-8") return meta -def test_absent_client_is_advertised_as_nothing_at_all(): - """The KEY is missing, not null. +# --- the table --------------------------------------------------------------- - A client testing for it then gets one unambiguous answer rather than having to - tell "this server has no APK" apart from "this server predates the feature". + +def test_every_platform_has_its_own_filenames(): + """Two platforms sharing an artifact or a sidecar name would overwrite each + other in the one directory they all live in — silently, and the survivor would + be whichever was copied last.""" + artifacts = [p.artifact for p in PLATFORMS] + sidecars = [p.sidecar for p in PLATFORMS] + assert len(set(artifacts)) == len(artifacts) + assert len(set(sidecars)) == len(sidecars) + assert not set(artifacts) & set(sidecars) + + +def test_the_android_names_are_the_ones_already_published(): + """Pinned because renaming them is a tidy-up that strands every installed phone. + + The Android lane publishes these exact names and CI bakes them in under them. """ - assert android_release() is None + assert APK_NAME == "thoughtsync.apk" + assert MANIFEST_NAME == "thoughtsync-android.json" + + +# --- absence is an ordinary answer ------------------------------------------- + + +@pytest.mark.parametrize("platform_id", ALL_IDS) +def test_an_absent_client_is_no_client(platform_id): + assert release(platform_id) is None + + +def test_a_server_holding_nothing_advertises_no_keys_at_all(): + """The KEYS are missing, not null. + + A client testing for one then gets an unambiguous answer rather than having to + tell "this server has no client" apart from "this server predates the feature". + """ + assert releases() == {} assert advertisement() == {} -def test_a_present_client_is_advertised_with_what_android_compares(): - place_client() - advertised = advertisement()["android_client"] - assert advertised["version"] == "0.1.216" - # The integer is what decides "is this newer", not the name — a name is a - # string and sorts like one. - assert advertised["version_code"] == 216 - assert advertised["size"] == len(PAYLOAD) - assert advertised["url"].endswith("/download") - - -def test_a_sidecar_describing_a_different_build_counts_as_no_client(): - """The likeliest real corruption: a new APK copied over an old sidecar. +@pytest.mark.parametrize("platform_id", ALL_IDS) +def test_a_sidecar_describing_a_different_build_counts_as_no_client(platform_id): + """The likeliest real corruption: a new artifact copied over an old sidecar. Serving one build while advertising another is worse than serving none — the - phone would compare versions against a promise the bytes do not keep. + client would compare versions against a promise the bytes do not keep. """ - place_client(size=999_999) - assert android_release() is None - assert advertisement() == {} + place(platform_id, size=999_999) + assert release(platform_id) is None -def test_an_unreadable_sidecar_counts_as_no_client(): - place_client() - (Config.client_root() / MANIFEST_NAME).write_text("{ this is not json", encoding="utf-8") - assert android_release() is None +@pytest.mark.parametrize("platform_id", ALL_IDS) +def test_an_unreadable_sidecar_counts_as_no_client(platform_id): + place(platform_id) + sidecar = Path(Config.client_root()) / BY_ID[platform_id].sidecar + sidecar.write_text("{ this is not json", encoding="utf-8") + assert release(platform_id) is None -def test_a_sidecar_missing_a_field_counts_as_no_client(): - root = Config.client_root() +@pytest.mark.parametrize("platform_id", ALL_IDS) +def test_a_sidecar_missing_a_field_counts_as_no_client(platform_id): + platform = BY_ID[platform_id] + root = Path(Config.client_root()) root.mkdir(parents=True, exist_ok=True) - (root / APK_NAME).write_bytes(PAYLOAD) - (root / MANIFEST_NAME).write_text(json.dumps({"version_name": "0.1.216"}), encoding="utf-8") - assert android_release() is None + (root / platform.artifact).write_bytes(PAYLOAD) + (root / platform.sidecar).write_text(json.dumps({"version_name": "x"}), encoding="utf-8") + assert release(platform_id) is None -def test_a_sidecar_with_no_apk_beside_it_counts_as_no_client(): - root = Config.client_root() +@pytest.mark.parametrize("platform_id", ALL_IDS) +def test_a_sidecar_with_no_artifact_beside_it_counts_as_no_client(platform_id): + platform = BY_ID[platform_id] + root = Path(Config.client_root()) root.mkdir(parents=True, exist_ok=True) - (root / MANIFEST_NAME).write_text(json.dumps({"version_name": "x", "version_code": 1, "size": 1, "sha256": ""})) - assert android_release() is None + (root / platform.sidecar).write_text( + json.dumps({"version_name": "x", "version_code": 1, "size": 1, "sha256": ""}), + encoding="utf-8", + ) + assert release(platform_id) is None -async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app): - place_client() - resp = await app.test_client().get("/api/client/android") - assert resp.status_code == 200 - assert (await resp.get_json())["version_code"] == 216 +def test_an_unknown_platform_is_not_a_client(): + assert release("blackberry") is None -async def test_metadata_404s_rather_than_describing_a_client_that_is_not_there(app): - resp = await app.test_client().get("/api/client/android") - assert resp.status_code == 404 +# --- what a present client reports ------------------------------------------- -async def test_the_bytes_need_authentication_even_though_the_version_does_not(app): - """Anyone who can reach the port may ask what version exists; only an account - or a linked device may pull the 55 MiB.""" - place_client() - resp = await app.test_client().get("/api/client/android/download") - assert resp.status_code == 401 +@pytest.mark.parametrize("platform_id", ALL_IDS) +def test_a_present_client_reports_what_a_comparator_reads(platform_id): + place(platform_id) + found = release(platform_id) + assert found["version"] == "2026.08.30.0307" + # The integer is what decides "is this newer", not the name — a name is a string + # and sorts like one. + assert found["version_code"] == 3503708 + assert found["size"] == len(PAYLOAD) + assert found["platform"] == platform_id + # A PATH, not an absolute URL: the client joins it to the base it is already + # linked to, so a server cannot redirect the download elsewhere. + assert found["url"] == f"/api/client/{platform_id}/download" + assert not found["url"].startswith("http") + + +def test_the_android_payload_still_carries_every_field_it_used_to(): + """Phones in the field parse this. Fields may be ADDED — `ClientRelease` in + core/src/sync/client.rs is a plain serde struct and ignores what it does not + know — but none of these may move or change meaning.""" + place("android") + found = android_release() + for key in ("version", "version_code", "size", "sha256", "url"): + assert key in found, key + assert found["url"] == "/api/client/android/download" + + +# --- the signed bundle ------------------------------------------------------- + + +def test_the_appimage_publishes_its_signature_with_its_version(): + """One request returns both, so an updater cannot pair a version with a + signature belonging to a different build.""" + place("linux-appimage", signature="minisign output here") + assert release("linux-appimage")["signature"] == "minisign output here" + + +def test_an_appimage_without_a_signature_is_absent_rather_than_unsigned(): + """It is the only bundle that replaces itself in place, and an update the app + cannot verify is one it will refuse. Offering it unverifiable would turn a + missing file into a failed install on the user's machine.""" + place("linux-appimage", signature=None) + assert release("linux-appimage") is None + + +def test_an_empty_signature_file_is_not_a_signature(): + """A truncated copy leaves a zero-byte file, which reads as present.""" + place("linux-appimage", signature=" \n") + assert release("linux-appimage") is None + + +def test_only_the_appimage_carries_a_signature(): + """A package-manager install cannot replace itself in place, so nothing verifies + one and claiming a signature would imply an update path that does not exist.""" + for platform_id in ALL_IDS: + place(platform_id) + found = releases() + assert "signature" in found["linux-appimage"] + for platform_id in ALL_IDS: + if platform_id != "linux-appimage": + assert "signature" not in found[platform_id], platform_id + + +# --- the set, and precedence within it --------------------------------------- + + +def test_a_platform_the_server_lacks_is_simply_not_in_the_set(): + """Not null, not an error — a server holding some clients and not others is the + ordinary state, and the UI hides what is absent.""" + place("android") + place("windows") + found = releases() + assert set(found) == {"android", "windows"} def test_the_baked_in_copy_is_used_when_nothing_was_dropped_in(): """The ordinary case for a self-hoster who just pulled the image.""" - place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300) + place("android", root=client_dist.BAKED_ROOT, version_code=300) assert android_release()["version_code"] == 300 def test_a_dropped_in_build_beats_the_one_the_image_shipped(): """Someone who deliberately put a build on the volume wants that build.""" - place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300) - place_client(version_name="0.1.99", version_code=99) - advertised = android_release() + place("android", root=client_dist.BAKED_ROOT, version_code=300) + place("android", version_code=99) # Lower version and all — precedence is about intent, not about newness. An # operator pinning an older client is doing it on purpose. - assert advertised["version_code"] == 99 + assert android_release()["version_code"] == 99 + + +def test_precedence_is_decided_per_platform_not_for_the_whole_set(): + """THE trap this table introduces. Dropping in one client must not retract the + other four — "first directory holding anything wins" would mean overriding the + APK silently takes the desktop downloads offline.""" + for platform_id in ALL_IDS: + place(platform_id, root=client_dist.BAKED_ROOT, version_code=300) + place("android", version_code=99) + found = releases() + assert found["android"]["version_code"] == 99 + assert found["linux-deb"]["version_code"] == 300 + assert set(found) == set(ALL_IDS) def test_a_broken_drop_in_does_not_shadow_the_baked_copy(): @@ -149,6 +280,79 @@ def test_a_broken_drop_in_does_not_shadow_the_baked_copy(): This is the failure the copy-order advice in docs/android-distribution.md is about, and the server should ride it out rather than go dark. """ - place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300) - place_client(size=999_999) # sidecar describing a different build + place("android", root=client_dist.BAKED_ROOT, version_code=300) + place("android", size=999_999) # sidecar describing a different build assert android_release()["version_code"] == 300 + + +# --- the advertisement ------------------------------------------------------- + + +def test_the_advertisement_carries_the_whole_table(): + place("linux-deb") + assert set(advertisement()["clients"]) == {"linux-deb"} + + +def test_the_advertisement_still_carries_the_key_phones_already_read(): + """Not deprecated in the change that introduces its replacement. An installed + Android client reads `android_client`, and one duplicated dict is what it costs + to not strand it.""" + place("android") + data = advertisement() + assert data["android_client"] == data["clients"]["android"] + + +def test_no_android_client_means_no_android_key_even_when_others_are_present(): + place("windows") + data = advertisement() + assert "android_client" not in data + assert set(data["clients"]) == {"windows"} + + +# --- routes ------------------------------------------------------------------ + + +@pytest.mark.parametrize("platform_id", ALL_IDS) +async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app, platform_id): + place(platform_id) + resp = await app.test_client().get(f"/api/client/{platform_id}") + assert resp.status_code == 200 + assert (await resp.get_json())["version_code"] == 3503708 + + +@pytest.mark.parametrize("platform_id", ALL_IDS) +async def test_metadata_404s_rather_than_describing_a_client_that_is_not_there(app, platform_id): + resp = await app.test_client().get(f"/api/client/{platform_id}") + assert resp.status_code == 404 + + +async def test_an_unknown_platform_404s_like_an_absent_one(app): + """Same answer to the caller either way, and telling them apart would only tell + an unauthenticated stranger which platforms this build of the server knows.""" + resp = await app.test_client().get("/api/client/blackberry") + assert resp.status_code == 404 + + +async def test_the_index_returns_everything_in_one_request(app): + place("android") + place("linux-appimage") + resp = await app.test_client().get("/api/client") + assert resp.status_code == 200 + assert set((await resp.get_json())["clients"]) == {"android", "linux-appimage"} + + +async def test_the_index_is_an_empty_set_rather_than_a_404(app): + """A server with no clients has an answer; it is just an empty one. 404 here + would make the UI treat "nothing to offer" as a broken endpoint.""" + resp = await app.test_client().get("/api/client") + assert resp.status_code == 200 + assert (await resp.get_json())["clients"] == {} + + +@pytest.mark.parametrize("platform_id", ALL_IDS) +async def test_the_bytes_need_authentication_even_though_the_version_does_not(app, platform_id): + """Anyone who can reach the port may ask what version exists; only an account or + a linked device may pull the payload.""" + place(platform_id) + resp = await app.test_client().get(f"/api/client/{platform_id}/download") + assert resp.status_code == 401