Compare commits
2 commits
1b38ce9d73
...
271c423918
| Author | SHA1 | Date | |
|---|---|---|---|
| 271c423918 | |||
| 55444ee55e |
7 changed files with 91 additions and 204 deletions
|
|
@ -1,7 +1,8 @@
|
|||
# Auto-update — contract (issue #16)
|
||||
|
||||
The app checks for a newer build and installs it, receiving the **adult** or
|
||||
**clean** variant according to its account's admin-set `nsfwEnabled` flag.
|
||||
The app checks for a newer build and installs it. One variant ships, `clean`;
|
||||
the server used to pick between that and an `adult` one from the account's
|
||||
`nsfwEnabled` flag, and no longer does.
|
||||
|
||||
Server pieces (this repo):
|
||||
- `pb_migrations/1786500000_releases.js` — the `releases` collection.
|
||||
|
|
@ -16,7 +17,7 @@ manifest fetch), the desktop swap-installer, and the Android APK install channel
|
|||
| Field | Type | Notes |
|
||||
|---------------|--------|-------|
|
||||
| `platform` | select | `windows` \| `linux` \| `android`. |
|
||||
| `variant` | select | `clean` \| `adult`. |
|
||||
| `variant` | select | `clean` \| `adult`. Only `clean` is published now. The field stays because every installed app compares it against its own and refuses a mismatch **silently** — removing it would strand those installs with no error to see. |
|
||||
| `version` | text | Human semver shown to the user, e.g. `1.0.1`. |
|
||||
| `buildNumber` | number | Monotonic int; the comparator the updater actually uses. |
|
||||
| `file` | file | The artifact: windows = `.exe` installer, linux = `.zip` of the release bundle, android = `.apk`. Protected (see rules). |
|
||||
|
|
@ -30,20 +31,20 @@ Unique index on `(platform, variant, buildNumber)`.
|
|||
**Access rules.** Read is gated:
|
||||
`@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)`
|
||||
— any signed-in account reads **clean** rows; only an `nsfwEnabled` account reads
|
||||
**adult** rows. The `file` field is **`protected: true`**, which is what gates the
|
||||
bytes: a protected file is served only with a short-lived file token
|
||||
(`POST /api/files/token`) whose grant **re-checks the view rule above** — so a
|
||||
non-flagged account can't download an adult artifact (and without the flag the
|
||||
file URL would be public regardless of the view rule). `create`/`update`/`delete`
|
||||
**adult** rows. That rule is deliberately left in place: `adult` rows from before
|
||||
the split are still in this collection, and the rule is what keeps them out of
|
||||
reach. The `file` field is **`protected: true`**, which is what gates the bytes:
|
||||
a protected file is served only with a short-lived file token
|
||||
(`POST /api/files/token`) whose grant **re-checks the view rule above** (and
|
||||
without the flag the file URL would be public regardless of the view rule). `create`/`update`/`delete`
|
||||
are **superuser-only** (null rules); publishing goes through the admin API.
|
||||
|
||||
## Endpoint
|
||||
|
||||
### `GET /api/update/manifest?platform=windows|linux|android` (auth: users)
|
||||
Returns the latest build for the caller's platform. The **variant is chosen
|
||||
server-side** from `nsfwEnabled` — the client cannot request adult.
|
||||
Returns the latest build for the caller's platform, always `variant: "clean"`.
|
||||
|
||||
- No release for that platform/variant → `{ "available": false }`.
|
||||
- No release for that platform → `{ "available": false }`.
|
||||
- Otherwise:
|
||||
```json
|
||||
{
|
||||
|
|
@ -81,16 +82,15 @@ bump `buildNumber` for each release.
|
|||
the zip. One artifact serves both jobs: people download and run it, and the app's
|
||||
updater runs the same file with `/SILENT`, which is why `DesktopInstaller`
|
||||
dispatches on the extension. The zip `release_windows.ps1` still builds beside it
|
||||
is for the flavour check and for unpacking by hand — do not publish it.
|
||||
|
||||
`check-flavor.py` cannot read a `.exe` (Inno compresses the payload) and refuses
|
||||
rather than passing it. The Windows flavour gate is `Assert-Flavor` in
|
||||
`release_windows.ps1`, which reads the staged folder before either artifact is
|
||||
made from it; run it there, not here.
|
||||
is for unpacking by hand — do not publish it.
|
||||
|
||||
## Gating summary
|
||||
|
||||
`nsfwEnabled` (admin-set on the `users` record) is the single source of truth:
|
||||
the manifest hook reads it to pick the variant, and the collection rules enforce
|
||||
it independently at read/download time. A no-flag account only ever sees and
|
||||
downloads clean builds.
|
||||
There is nothing left to gate between: one variant is published, and the manifest
|
||||
hook returns it to everyone.
|
||||
|
||||
`nsfwEnabled` still exists on the `users` record and the collection rules still
|
||||
read it. Both stay because `adult` rows from before the split are still in the
|
||||
collection, and those rules are what keeps them unreachable. Nothing writes the
|
||||
flag any more: the manifest hook ignores it, `whoami` no longer returns it, and
|
||||
the status dashboard's toggle is gone.
|
||||
|
|
|
|||
|
|
@ -75,16 +75,20 @@ routerAdd("GET", "/api/status", (e) => {
|
|||
|
||||
// ---- what the family is being served (local DB, always available) ------
|
||||
const releases = safe("releases", () => {
|
||||
// Newest build per platform+variant. Six rows in practice; a 200-row window
|
||||
// is plenty and bounds the query regardless of history size.
|
||||
const rows = $app.findRecordsByFilter("releases", "id != ''", "-buildNumber", 200, 0)
|
||||
// Newest build per platform. Three rows in practice; a 200-row window is
|
||||
// plenty and bounds the query regardless of history size.
|
||||
//
|
||||
// Filtered to variant='clean' rather than reading whatever is newest: rows
|
||||
// from the 18+ flavour are still in this collection, and a leftover one
|
||||
// could otherwise be reported as what the family is being served.
|
||||
const rows = $app.findRecordsByFilter(
|
||||
"releases", "variant = 'clean'", "-buildNumber", 200, 0)
|
||||
const newest = {}
|
||||
for (const r of rows) {
|
||||
const key = r.getString("platform") + ":" + r.getString("variant")
|
||||
const key = r.getString("platform")
|
||||
if (newest[key]) continue
|
||||
newest[key] = {
|
||||
platform: r.getString("platform"),
|
||||
variant: r.getString("variant"),
|
||||
version: r.getString("version"),
|
||||
build: r.getInt("buildNumber"),
|
||||
size: r.getInt("size"),
|
||||
|
|
@ -129,9 +133,7 @@ routerAdd("GET", "/api/status", (e) => {
|
|||
|
||||
const accounts = safe("accounts", () => {
|
||||
const rows = $app.findRecordsByFilter("users", "id != ''", "", 500, 0)
|
||||
let adult = 0
|
||||
for (const r of rows) if (r.getBool("nsfwEnabled")) adult++
|
||||
return { total: rows.length, adult: adult, clean: rows.length - adult }
|
||||
return { total: rows.length }
|
||||
})
|
||||
|
||||
// ---- containers (Coolify) --------------------------------------------
|
||||
|
|
@ -243,16 +245,7 @@ routerAdd("GET", "/api/status/errors", (e) => {
|
|||
const ev = r.getString("event") || ""
|
||||
return kind === "error" || ev === "uncaught" || ev === "player_error" || ev === "exo_error"
|
||||
}
|
||||
// Forward-compatible: works the moment the app starts sending a flavour.
|
||||
const flavourOf = (r) => {
|
||||
const top = r.getString("flavor") || r.getString("flavour")
|
||||
if (top) return top
|
||||
const meta = r.getString("meta") || ""
|
||||
const m = /"flavou?r"\s*:\s*"([a-z]+)"/.exec(meta)
|
||||
return m ? m[1] : "neznámá"
|
||||
}
|
||||
|
||||
const days_ = {}, byVersion = {}, byPlatform = {}, byFlavour = {}, byEvent = {}
|
||||
const days_ = {}, byVersion = {}, byPlatform = {}, byEvent = {}
|
||||
// Pre-seed every day so the graph has no gaps — a missing bar and a zero bar
|
||||
// mean different things and must not look alike.
|
||||
for (let i = 0; i < days; i++) {
|
||||
|
|
@ -272,7 +265,6 @@ routerAdd("GET", "/api/status/errors", (e) => {
|
|||
if (days_[day]) { days_[day].total++; if (err) days_[day].errors++ }
|
||||
bump(byVersion, r.getString("appVersion") || "?", err)
|
||||
bump(byPlatform, r.getString("platform") || "?", err)
|
||||
bump(byFlavour, flavourOf(r), err)
|
||||
if (err) {
|
||||
const ev = r.getString("event") || "?"
|
||||
byEvent[ev] = (byEvent[ev] || 0) + 1
|
||||
|
|
@ -293,7 +285,6 @@ routerAdd("GET", "/api/status/errors", (e) => {
|
|||
days: Object.keys(days_).sort().map((k) => days_[k]),
|
||||
byVersion: list(byVersion),
|
||||
byPlatform: list(byPlatform),
|
||||
byFlavour: list(byFlavour),
|
||||
topErrorEvents: Object.keys(byEvent).sort((a, b) => byEvent[b] - byEvent[a])
|
||||
.slice(0, 8).map((k) => ({ key: k, errors: byEvent[k] })),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,13 +4,19 @@
|
|||
//
|
||||
// GET /api/update/manifest?platform=windows|linux|android (auth: users)
|
||||
//
|
||||
// Returns the latest published build for the caller's platform. The VARIANT is
|
||||
// chosen server-side from the account's admin-set `nsfwEnabled` (adult) vs clean
|
||||
// — the client never asks for adult. This is belt-and-suspenders on top of the
|
||||
// `releases` collection rules, which already hide adult rows from non-flagged
|
||||
// accounts. The download is PocketBase's native protected file endpoint: the app
|
||||
// mints a short-lived file token (`POST /api/files/token`) and appends it to
|
||||
// `downloadPath`, and the token grant re-checks the same gating rule.
|
||||
// Returns the latest published build for the caller's platform. The download is
|
||||
// PocketBase's native protected file endpoint: the app mints a short-lived file
|
||||
// token (`POST /api/files/token`) and appends it to `downloadPath`, and the token
|
||||
// grant re-checks the collection rule.
|
||||
//
|
||||
// `variant` used to be chosen here from the account's admin-set `nsfwEnabled`,
|
||||
// picking an 18+ build over the clean one. Amber has no 18+ build any more, so it
|
||||
// is a constant.
|
||||
//
|
||||
// It is NOT removed from the response, and must not be. Every Amber already
|
||||
// installed compares this field against its own and refuses a mismatch SILENTLY
|
||||
// — no error, no prompt, the update simply never appears. Drop it, or send
|
||||
// anything but "clean", and those installs stop updating with nothing to see.
|
||||
//
|
||||
// NOTE: PocketBase runs each routerAdd handler in its own isolated JSVM, so the
|
||||
// small helpers live inside the handler.
|
||||
|
|
@ -19,8 +25,8 @@ routerAdd("GET", "/api/update/manifest", (e) => {
|
|||
const platform = (e.request.url.query().get("platform") || "").toLowerCase().trim()
|
||||
if (ALLOWED.indexOf(platform) === -1) throw new BadRequestError("bad or missing platform")
|
||||
|
||||
// The account flag decides the channel; a client can't opt into adult.
|
||||
const variant = e.auth.getBool("nsfwEnabled") ? "adult" : "clean"
|
||||
// One build ships. See the note above for why this field survives at all.
|
||||
const variant = "clean"
|
||||
|
||||
let rec = null
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
// Token introspection for amber-api (the facts service).
|
||||
//
|
||||
// GET /api/amber/whoami (auth: users) → { id, nsfwEnabled }
|
||||
// GET /api/amber/whoami (auth: users) → { id }
|
||||
//
|
||||
// WHY THIS EXISTS: amber-api has to know whether an inbound request carries a
|
||||
// valid Amber user token before it will spend an ffprobe on a caller-supplied
|
||||
|
|
@ -13,13 +13,14 @@
|
|||
// which `$apis.requireAuth("users")` does before this handler ever runs.
|
||||
//
|
||||
// So the handler body is deliberately trivial: reaching it *is* the answer. It
|
||||
// returns the two facts amber-api needs and nothing else — no email, no
|
||||
// username, no profile list. A token-introspection endpoint is a tempting place
|
||||
// to leak account data, and amber-api has no business knowing any of it.
|
||||
// returns the one fact amber-api needs and nothing else — no email, no username,
|
||||
// no profile list. A token-introspection endpoint is a tempting place to leak
|
||||
// account data, and amber-api has no business knowing any of it.
|
||||
//
|
||||
// `nsfwEnabled` is included because it's the same admin-only flag that gates
|
||||
// adult release channels, and a future amber-api surface may need to scope
|
||||
// results by it. It is read-only here.
|
||||
// It used to return `nsfwEnabled` too, on the reasoning that a future amber-api
|
||||
// surface might scope results by it. No such surface was ever built, adult
|
||||
// content is not something Amber serves, and an account flag nobody reads is
|
||||
// exactly the kind of thing this endpoint should not be handing out.
|
||||
//
|
||||
// amber-api caches positives for ~10 minutes and negatives for ~30 seconds, so
|
||||
// this route sees roughly one request per device per 10 minutes — cheap enough
|
||||
|
|
@ -29,8 +30,5 @@
|
|||
// anything a handler needs must be declared INSIDE it — file-scope helpers throw
|
||||
// ReferenceError at request time. Nothing is hoisted here; keep it that way.
|
||||
routerAdd("GET", "/api/amber/whoami", (e) => {
|
||||
return e.json(200, {
|
||||
id: e.auth.id,
|
||||
nsfwEnabled: e.auth.getBool("nsfwEnabled"),
|
||||
})
|
||||
return e.json(200, { id: e.auth.id })
|
||||
}, $apis.requireAuth("users"))
|
||||
|
|
|
|||
29
pb_migrations/1793000000_releases_drop_adult_rule.js
Normal file
29
pb_migrations/1793000000_releases_drop_adult_rule.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// The `releases` read rule stops mentioning nsfwEnabled.
|
||||
//
|
||||
// It used to read:
|
||||
// @request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)
|
||||
// — any signed-in account could read clean rows, and only an nsfwEnabled account
|
||||
// could read the 18+ ones. That clause was the thing keeping 18+ artifacts out of
|
||||
// reach of the family.
|
||||
//
|
||||
// Amber has no 18+ build any more and the nine rows that carried one (three
|
||||
// versions across three platforms, 437 MB) were deleted before this migration.
|
||||
// With nothing left for the clause to hide, it is a condition on a flag nothing
|
||||
// writes, which reads like a protection that is still doing something.
|
||||
//
|
||||
// `variant` itself stays on the collection and in the manifest: every installed
|
||||
// Amber compares that field against its own and refuses a mismatch silently.
|
||||
migrate((app) => {
|
||||
const c = app.findCollectionByNameOrId("releases")
|
||||
c.listRule = "@request.auth.id != ''"
|
||||
c.viewRule = "@request.auth.id != ''"
|
||||
app.save(c)
|
||||
}, (app) => {
|
||||
const c = app.findCollectionByNameOrId("releases")
|
||||
const rule = "@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)"
|
||||
c.listRule = rule
|
||||
c.viewRule = rule
|
||||
app.save(c)
|
||||
})
|
||||
|
|
@ -290,12 +290,12 @@ function panelReleases(rs){
|
|||
esc(rs && rs.error || "—") + "</p></section>";
|
||||
if (!rs.length) return '<section><h2>Vydání</h2><p class="note">nic zveřejněno</p></section>';
|
||||
var body = rs.map(function(r){
|
||||
return "<tr><td>" + esc(r.platform) + "</td><td>" + esc(r.variant) +
|
||||
return "<tr><td>" + esc(r.platform) +
|
||||
"</td><td class='mono'>" + esc(r.version) + "+" + r.build +
|
||||
"</td><td class='mono'>" + esc(r.sha8) + "</td><td class='num'>" + bytes(r.size) + "</td></tr>";
|
||||
}).join("");
|
||||
return '<section class="wide"><h2>Vydání — co se rozdává</h2><table>' +
|
||||
"<tr><th>platforma</th><th>varianta</th><th>verze</th><th>sha256</th><th class='num'>velikost</th></tr>" +
|
||||
"<tr><th>platforma</th><th>verze</th><th>sha256</th><th class='num'>velikost</th></tr>" +
|
||||
body + "</table>" +
|
||||
'<p class="note">Buildy se nemusí shodovat mezi platformami — když se vydává jen ' +
|
||||
"pro počítače, televize zůstane na starším čísle a nedostane výzvu k aktualizaci." +
|
||||
|
|
@ -345,8 +345,6 @@ function panelAccounts(a){
|
|||
if (!a || a.error) return "";
|
||||
return '<section><h2>Účty</h2><div class="kv">' +
|
||||
'<div><div class="lbl">celkem</div><div class="big">' + a.total + "</div></div>" +
|
||||
'<div><div class="lbl">clean</div><div class="big">' + a.clean + "</div></div>" +
|
||||
'<div><div class="lbl">18+</div><div class="big">' + a.adult + "</div></div>" +
|
||||
'</div><p class="note">Nový účet se zakládá ve <b>Správě</b>.</p></section>';
|
||||
}
|
||||
|
||||
|
|
@ -408,7 +406,6 @@ function panelErrors(d){
|
|||
(d.truncated ? " (zkráceno)" : "") + "</span></div>" +
|
||||
tbl("Podle vydání — podíl chyb, ne počet", d.byVersion, true) +
|
||||
tbl("Podle systému", d.byPlatform, true) +
|
||||
tbl("Podle varianty", d.byFlavour, true) +
|
||||
tbl("Nejčastější chyby", (d.topErrorEvents || []).map(function(x){
|
||||
return { key: x.key, total: x.errors, errors: x.errors }; }), false) +
|
||||
'<p class="note">Historie je omezená retencí: client_logs se každou noc čistí ' +
|
||||
|
|
@ -658,13 +655,8 @@ async function setNoticeActive(id, to){
|
|||
function viewAdmin(users, tpls){
|
||||
var rows = (users || []).map(function(u){
|
||||
return "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" +
|
||||
"<td>" + (u.nsfwEnabled ? '<span class="pill wn">18+</span>'
|
||||
: '<span class="pill ok">clean</span>') + "</td>" +
|
||||
"<td class='mono'>" + esc(u.ratingDefault || "—") + "</td>" +
|
||||
"<td class='mono'>" + esc(String(u.created).substring(0, 10)) + "</td>" +
|
||||
'<td><button data-nsfw="' + esc(u.id) + '" data-to="' +
|
||||
(u.nsfwEnabled ? "0" : "1") + '">' +
|
||||
(u.nsfwEnabled ? "zakázat 18+" : "povolit 18+") + "</button></td></tr>";
|
||||
"<td class='mono'>" + esc(String(u.created).substring(0, 10)) + "</td></tr>";
|
||||
}).join("");
|
||||
|
||||
return '<section class="wide"><h2>Nový účet</h2>' +
|
||||
|
|
@ -674,9 +666,6 @@ function viewAdmin(users, tpls){
|
|||
'<div><div class="lbl">jméno (nepovinné)</div><input id="nName" autocomplete="off"></div>' +
|
||||
'<div><div class="lbl">heslo</div><input id="nPass" type="text" autocomplete="off" ' +
|
||||
'placeholder="nejméně 8 znaků"></div>' +
|
||||
'<label class="row span" style="color:var(--muted);font-size:13px">' +
|
||||
'<input type="checkbox" id="nNsfw" style="width:auto;accent-color:var(--accent)"> ' +
|
||||
"povolit 18+ (jinak dostane clean build)</label>" +
|
||||
'<div class="span"><div class="lbl">sdílené nastavení</div>' +
|
||||
'<select id="nGroup" style="width:100%">' +
|
||||
'<option value="">žádné — zdroje si zadá sám</option>' +
|
||||
|
|
@ -699,7 +688,7 @@ function viewAdmin(users, tpls){
|
|||
"tohle jde přes superuživatele, kterým jsi právě přihlášený.</p></section>" +
|
||||
|
||||
'<section class="wide"><h2>Účty</h2><table class="accts">' +
|
||||
"<tr><th>e-mail</th><th>varianta</th><th>strop</th><th>vznik</th><th></th></tr>" +
|
||||
"<tr><th>e-mail</th><th>strop</th><th>vznik</th></tr>" +
|
||||
(rows || "<tr><td colspan=5 class='note'>nic</td></tr>") + "</table>" +
|
||||
'<p class="note">Varianta rozhoduje, jaký build dostane přes automatickou ' +
|
||||
"aktualizaci — server ji vybírá podle tohohle příznaku, klient si ji nemůže " +
|
||||
|
|
@ -952,7 +941,6 @@ async function createAccount(){
|
|||
try {
|
||||
var body = {
|
||||
email: email, password: pass, passwordConfirm: pass,
|
||||
nsfwEnabled: el("nNsfw").checked,
|
||||
// Verified up front: the family never goes through an e-mail confirmation,
|
||||
// and an unverified account would just be a trap nobody could clear.
|
||||
verified: true,
|
||||
|
|
@ -993,38 +981,20 @@ async function createAccount(){
|
|||
}
|
||||
}
|
||||
out.innerHTML = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) +
|
||||
"</code> je založený" + (el("nNsfw").checked ? " s 18+" : " jako clean") +
|
||||
"." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
|
||||
"</code> je založený." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
|
||||
el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = "";
|
||||
el("nNsfw").checked = false; el("nRating").value = "";
|
||||
el("nRating").value = "";
|
||||
load();
|
||||
} catch (e) {
|
||||
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
async function setNsfw(id, to){
|
||||
try {
|
||||
var r = await fetch("/api/collections/users/records/" + encodeURIComponent(id), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: tok },
|
||||
body: JSON.stringify({ nsfwEnabled: to === "1" }),
|
||||
});
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
load();
|
||||
} catch (e) {
|
||||
el("main").insertAdjacentHTML("afterbegin",
|
||||
'<section class="wide"><div class="warnbox">' + esc(e.message) + "</div></section>");
|
||||
}
|
||||
}
|
||||
|
||||
function wire(){
|
||||
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){
|
||||
b.onclick = function(){ showLog(b.getAttribute("data-log"), b); };
|
||||
});
|
||||
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-nsfw]"), function(b){
|
||||
b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); };
|
||||
});
|
||||
if (el("createBtn")) el("createBtn").onclick = createAccount;
|
||||
if (el("nSave")) el("nSave").onclick = saveNotice;
|
||||
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-notice]"), function(b){
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Verify a built artifact's flavour without installing or launching it.
|
||||
|
||||
check-flavor.py <artifact> <expected: adult|clean>
|
||||
|
||||
Exits non-zero unless the artifact contains exactly one `AMBER_FLAVOR::` marker
|
||||
and it matches. Handles an `.apk`, a Linux bundle `.zip`, a Windows bundle `.zip`
|
||||
and an unpacked build **directory** — it finds the Dart AOT snapshot inside each.
|
||||
|
||||
A Windows `.exe` installer cannot be checked here and is refused rather than
|
||||
passed: Inno LZMA-compresses the payload, so the marker only exists in a form
|
||||
nothing can grep. That is why the release script gates the staged folder before
|
||||
packaging instead of gating the artifact afterwards.
|
||||
|
||||
**Why this exists.** The clean flavour is what anonymous downloads and every
|
||||
child profile receive, so publishing an adult build into a clean slot is the
|
||||
worst mistake this release process can make — and until now the only way to tell
|
||||
the two apart offline was to install the APK on a television and look at the tab
|
||||
row. That made a content-safety gate depend on a TV being awake and on adb still
|
||||
being authorised, which is exactly how it gets skipped "just this once".
|
||||
|
||||
Nothing cheaper worked. Class names such as `AdultBrowse` survive in **both**
|
||||
snapshots whatever the dart-define, and the `'adult'`/`'clean'` strings the
|
||||
updater compares sit inside a function body, so both literals ship in both
|
||||
builds. The two APKs are also routinely **byte-identical in size** (zip
|
||||
alignment absorbs the difference), so size proves nothing either. `config.dart`
|
||||
therefore compiles in a deliberate const-folded marker; see [kFlavorMarker].
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
MARKER = re.compile(rb'AMBER_FLAVOR::(adult|clean)')
|
||||
|
||||
# Where the Dart AOT snapshot lives, per artifact kind.
|
||||
SNAPSHOTS = (
|
||||
'lib/arm64-v8a/libapp.so', # apk
|
||||
'lib/armeabi-v7a/libapp.so', # apk, 32-bit
|
||||
'lib/libapp.so', # linux bundle zip
|
||||
'data/app.so', # windows bundle zip
|
||||
)
|
||||
|
||||
|
||||
def markers_in(blob: bytes) -> set:
|
||||
return {m.group(1).decode() for m in MARKER.finditer(blob)}
|
||||
|
||||
|
||||
def main(path: str, expected: str) -> int:
|
||||
if expected not in ('adult', 'clean'):
|
||||
print(f'error: expected must be adult|clean, got {expected!r}')
|
||||
return 2
|
||||
|
||||
if path.lower().endswith('.exe'):
|
||||
# Say why rather than throwing a BadZipFile, and fail rather than pass:
|
||||
# a checker that prints something reassuring about an artifact it never
|
||||
# read is worse than no checker.
|
||||
print(f'FAIL {path}: an Inno installer LZMA-compresses its payload, so '
|
||||
'the marker is not readable here.\n'
|
||||
' The gate for a .exe is Assert-Flavor in '
|
||||
'amber-app/scripts/release_windows.ps1, which reads the staged\n'
|
||||
' folder BEFORE packaging. Point this script at that folder, '
|
||||
'or at the .zip built beside the installer.')
|
||||
return 1
|
||||
|
||||
found = set()
|
||||
checked = []
|
||||
if os.path.isdir(path):
|
||||
# The staged build folder, which is where the truth actually lives.
|
||||
for candidate in SNAPSHOTS:
|
||||
f = os.path.join(path, candidate.replace('/', os.sep))
|
||||
if os.path.isfile(f):
|
||||
checked.append(candidate)
|
||||
with open(f, 'rb') as fh:
|
||||
found |= markers_in(fh.read())
|
||||
else:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = set(z.namelist())
|
||||
for candidate in SNAPSHOTS:
|
||||
if candidate in names:
|
||||
checked.append(candidate)
|
||||
found |= markers_in(z.read(candidate))
|
||||
|
||||
if not checked:
|
||||
# Better to fail loudly than to pass an artifact nothing was read from.
|
||||
print(f'FAIL {path}: no Dart snapshot found (looked for {SNAPSHOTS})')
|
||||
return 1
|
||||
if not found:
|
||||
print(f'FAIL {path}: no AMBER_FLAVOR marker in {checked} — '
|
||||
'built before the marker existed?')
|
||||
return 1
|
||||
if len(found) > 1:
|
||||
print(f'FAIL {path}: ambiguous, contains {sorted(found)}')
|
||||
return 1
|
||||
|
||||
actual = found.pop()
|
||||
if actual != expected:
|
||||
print(f'FAIL {path}: is {actual!r}, expected {expected!r}')
|
||||
return 1
|
||||
print(f'ok {path}: {actual} (from {", ".join(checked)})')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit(__doc__)
|
||||
raise SystemExit(main(sys.argv[1], sys.argv[2]))
|
||||
Loading…
Reference in a new issue