server: hand out the Android client this server syncs with (2726)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.
It also keeps the pair in step by construction. Client and server 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.
**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.
**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.
**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.
**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.
**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.
The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.
Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
This commit is contained in:
@@ -12,6 +12,7 @@ from quart.sessions import SecureCookieSessionInterface
|
||||
|
||||
from . import __version__
|
||||
from .auth import bp as auth_bp
|
||||
from .client_dist import advertisement as client_advertisement, bp as client_bp
|
||||
from .config import Config
|
||||
from .db import session_scope
|
||||
from .graph import bp as graph_bp
|
||||
@@ -71,6 +72,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(sync_bp)
|
||||
app.register_blueprint(saved_filters_bp)
|
||||
app.register_blueprint(client_bp)
|
||||
|
||||
@app.before_serving
|
||||
async def _bootstrap() -> None:
|
||||
@@ -113,6 +115,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.
|
||||
data.update(client_advertisement())
|
||||
return jsonify(data)
|
||||
|
||||
@app.get("/", defaults={"path": ""})
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""The server hands out the Android client it is in step with.
|
||||
|
||||
## Why the server, and not a release page
|
||||
|
||||
The Fabled-Git instance is private (issue 2091), so `install.sh` already cannot
|
||||
fetch for anyone but the operator — and a self-hoster should not need an account
|
||||
on someone else's forge to get the app for their own notes. The server they
|
||||
already trust with the notes is the obvious place to get the client from.
|
||||
|
||||
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
|
||||
|
||||
`DATA_DIR/client/` — the same volume that already holds attachments, so an
|
||||
operator drops a build there once and container recreation does not lose it.
|
||||
Deliberately NOT baked into the image: that would charge ~55 MiB to every
|
||||
self-hoster, including everyone who never touches Android.
|
||||
|
||||
Two files, and both must be present:
|
||||
|
||||
- `thoughtsync.apk` — the client
|
||||
- `thoughtsync-android.json` — `{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.
|
||||
|
||||
## 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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
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"
|
||||
|
||||
bp = Blueprint("client_dist", __name__)
|
||||
|
||||
|
||||
def android_release() -> dict | None:
|
||||
"""What Android build this server holds, or None if it holds 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.
|
||||
"""
|
||||
root = Path(Config.client_root())
|
||||
apk = root / APK_NAME
|
||||
try:
|
||||
size = apk.stat().st_size
|
||||
meta = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||
version = str(meta["version_name"])
|
||||
code = int(meta["version_code"])
|
||||
recorded = int(meta["size"])
|
||||
digest = str(meta["sha256"])
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
return 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.
|
||||
if recorded != size:
|
||||
return None
|
||||
|
||||
return {
|
||||
"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.
|
||||
"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.
|
||||
"sha256": digest,
|
||||
"url": DOWNLOAD_PATH,
|
||||
}
|
||||
|
||||
|
||||
def advertisement() -> dict:
|
||||
"""The `/api/config` fragment describing this server's Android client.
|
||||
|
||||
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".
|
||||
"""
|
||||
release = android_release()
|
||||
return {"android_client": release} if release else {}
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@bp.get(DOWNLOAD_PATH)
|
||||
@login_required
|
||||
async def android_download():
|
||||
"""The APK itself.
|
||||
|
||||
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.
|
||||
"""
|
||||
if android_release() is None:
|
||||
return jsonify({"error": "this server has no Android client"}), 404
|
||||
response = await send_from_directory(
|
||||
Path(Config.client_root()), APK_NAME, mimetype=APK_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}"'
|
||||
return response
|
||||
@@ -33,6 +33,17 @@ class Config:
|
||||
def media_root(cls) -> Path:
|
||||
return Path(cls.DATA_DIR) / "media"
|
||||
|
||||
@classmethod
|
||||
def client_root(cls) -> Path:
|
||||
"""Where the Android APK this server hands out lives.
|
||||
|
||||
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.
|
||||
"""
|
||||
return Path(cls.DATA_DIR) / "client"
|
||||
|
||||
@classmethod
|
||||
def secret_key_env(cls) -> str | None:
|
||||
"""Optional break-glass override for the cookie-signing secret."""
|
||||
|
||||
Reference in New Issue
Block a user