Close the gaps family idea #5103 found in how Inkwell distributes its app
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 18s
Android / Core and FFI clippy and tests (push) Successful in 45s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m50s
Android / Build the server image (push) Successful in 2s

Three of the idea's practices this project still owed (Scribe #5118):

Practice 3, CI fails on the wrong signer. The signing step printed the
certificate and went on. It now fails unless the APK has exactly one
signer and that signer is the release certificate (SHA-256 408a5835…,
pinned from run 8753). The steps that publish come after it in the same
job, so a wrongly signed build is never staged or published.

Practice 6, app downloads are throttled and carry a sha256 ETag.
- The download route counts per account and answers 429 with
  Retry-After past the limit. The limit is a new Settings → Security
  value, "App downloads per account per hour" (default 30), live like the
  sign-in limits.
- The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a
  phone resuming a download across a redeploy is not told its partial
  copy is stale. Quart's own ETag and conditional handling are off, and
  the route runs the conditional pass after setting the ETag, so Range
  and If-Range are judged against the content.

Practice 9, the update offer and debug builds.
- The install-permission notice re-reads the grant each time the app
  comes back, as ReminderNotice does. Read once, it stayed up after
  someone granted the permission in Settings and came back.
- A debuggable build says it can't update itself and checks for nothing.
  Android would refuse the release-signed APK over a debug signature
  anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:21:40 -04:00
co-authored by Claude Opus 5.5
parent f0ce5687cc
commit 97b04f9f92
10 changed files with 202 additions and 9 deletions
+61
View File
@@ -422,3 +422,64 @@ async def test_a_server_without_the_appimage_404s_its_updater_manifest(app):
"up to date", so the 404 has to be there to turn."""
resp = await app.test_client().get("/api/client/linux-appimage/update.json")
assert resp.status_code == 404
# --- serving the bytes (#5118, family idea #5103 practice 6) -----------------
#
# `send_artifact` is called directly in a request context: the route in front of it
# needs a signed-in account, and this suite has no database to sign one in with.
async def _send(app, headers: dict):
place("android")
found = release("android")
root = Path(Config.client_root())
async with app.test_request_context("/api/client/android/download", headers=headers):
resp = await client_dist.send_artifact(root, BY_ID["android"], found)
return resp, await resp.get_data()
async def test_the_etag_is_the_sha256_the_sidecar_records(app):
"""Content identity, not Quart's mtime-and-path: the same bytes after a redeploy
must still match a partial download a phone is resuming."""
resp, body = await _send(app, {})
assert resp.status_code == 200
assert resp.headers["ETag"] == f'"{"ab" * 32}"'
assert body == PAYLOAD
async def test_a_range_request_gets_just_that_range(app):
resp, body = await _send(app, {"Range": "bytes=4-9"})
assert resp.status_code == 206
assert body == PAYLOAD[4:10]
assert resp.headers["Content-Range"] == f"bytes 4-9/{len(PAYLOAD)}"
assert resp.headers["Accept-Ranges"] == "bytes"
async def test_resuming_a_different_build_starts_again_from_the_top(app):
"""If-Range names the build the partial copy came from. A different one must
not be stitched onto it: the whole file comes back instead."""
resp, body = await _send(app, {"Range": "bytes=4-9", "If-Range": f'"{"cd" * 32}"'})
assert resp.status_code == 200
assert body == PAYLOAD
async def test_resuming_the_same_build_continues_it(app):
resp, body = await _send(app, {"Range": "bytes=4-9", "If-Range": f'"{"ab" * 32}"'})
assert resp.status_code == 206
assert body == PAYLOAD[4:10]
async def test_a_client_holding_this_build_is_told_nothing_changed(app):
resp, body = await _send(app, {"If-None-Match": f'"{"ab" * 32}"'})
assert resp.status_code == 304
assert body == b""
def test_the_download_throttle_reads_its_limit_from_settings():
"""The limit an admin saves is the one that applies, without a restart."""
from inkwell import ratelimit
from inkwell.settings import live
assert ratelimit.downloads_by_account.limit == live("client_downloads_per_hour")
assert ratelimit.downloads_by_account.window_s == 3600.0
+1
View File
@@ -480,6 +480,7 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
"register_limit_per_address",
"register_window_minutes",
"reset_emails_per_account",
"client_downloads_per_hour",
}
# The UI renders a number input from these, and it cannot offer a safe range it
# was never told about.