AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+255
-69
@@ -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 `:<version>` 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: `<artifact>.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/<platform_id>")
|
||||
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/<platform_id>/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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+273
-69
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user