diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index fb6b6f9..109ce24 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -163,7 +163,17 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif - **Delivery:** `actions/upload-artifact@v4` (assumes Gitea 1.26 artifact support — verify at the §G dispatch). Secrets to set before a **signed** build (§G / publication): `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. -- **F–G — pending.** +- **F. Docs — ✅ DONE (2026-07-12).** Baked the Android work into the live docs: `docs/ARCHITECTURE.md` §2 + (the client-version gate + the frozen wire contract + the gate × offline rule), §3 (the local-guest / + server-guest / reconciliation identity model) and §13 (the native Capacitor build — bundle model, bundled + dicts, `versionCode` scheme, `file://` origin, RuStore); `docs/FUNCTIONAL.md` (+ `_ru` mirror) a new **Native + app (Android)** domain (offline-first guest launch, email soft-registration, no TG/VK login, hidden + purchases, update-required); `docs/TESTING.md` (the `clientver`/gate Go tests, the `native`/`update` e2e + + the `retry` mapping, and the manual **on-device Android smoke checklist**); `deploy/README.md` (the + `GATEWAY_MIN_CLIENT_VERSION` var + the wire-break bump discipline + the Android build/release runbook — + keystore, secrets, dispatch, RuStore upload); `ui/README.md` (the native `VITE_*` build vars). Every + referenced test file was confirmed to exist. +- **G — pending.** Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate *every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today @@ -655,7 +665,7 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`: (`> Value is null`). The signing block attaches only when `ANDROID_KEYSTORE_FILE` exists, so a keyless `assembleRelease` (and every `assembleDebug`) builds UNSIGNED instead of failing. -### F. Docs (bake in the same PR) +### F. Docs (bake in the same PR) — ✅ DONE - `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** + the gate×offline rule. New sections — the **identity model** (local guest / server guest / diff --git a/deploy/README.md b/deploy/README.md index 07833c1..9b4e523 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -118,6 +118,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org` | `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. | | `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. | | `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. | +| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | Minimum client build the gateway will serve — the native client-version gate (ARCHITECTURE.md §2). Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind. Validated at load; a non-empty unparseable value fails startup. | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). | | `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). | @@ -236,6 +237,32 @@ redeploy the matching old tag. This dump is a belt-and-braces net for a bad migr **point-in-time recovery** (below) is the primary recovery path once armed, and the only one that survives losing the host. +## Android app build & release (RuStore) + +The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the +web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md). + +- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors + `prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything + but a clean `vMAJOR.MINOR.PATCH` and derives `versionCode = MA*1_000_000 + MI*1_000 + PA`, + `versionName = MA.MI.PA`. Watch it green with `python3 ~/.claude/bin/gitea-ci-watch.py`. +- **Output:** a `release APK` run artifact — **signed** when the signing secrets are present, an + **unsigned** release APK otherwise (a dry run still proves the pipeline). +- **Runner (host-executor):** JDK 21 comes from `setup-java`; the **Android SDK is host-provisioned** — + install it once (`sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0'`) and grant the + `runner` user read+exec (`sudo chmod -R a+rX /opt/android-sdk`). Override the path with the + `ANDROID_SDK_DIR` variable. The workflow's `Verify the host Android SDK` step fails fast with the fix if + the SDK is missing or unreadable. +- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated): + `keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`, + then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`), + `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL` + variable to the store listing once published (empty until then — the in-app update button no-ops). +- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps + `GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned + away cleanly instead of failing blind. +- **Upload:** download the artifact and upload it to RuStore by hand (no automated RuStore-API upload in the MVP). + ## Point-in-time recovery (PITR) The main host archives Postgres continuously with **pgBackRest** to **Selectel S3** diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 75723cb..6aeac0f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -140,6 +140,30 @@ dropped). Horizontal scaling is explicit future work. (your-turn, opponent-moved, chat, nudge). The gateway bridges them to the client's in-app stream while the app is open. Out-of-app delivery uses platform-native push via the platform side-service. +- **Client-version gate** (native long-tail protection): a bundled native install (§13) can be + arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app + version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers + payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload — + because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer + that breaks. Enforcement is **per-call, not a Hello RPC**: `Execute` and `Subscribe` compare the header to + `GATEWAY_MIN_CLIENT_VERSION` at the top (before registry lookup / auth / decode), so the first online call + an app makes is already gated. A too-old client gets the domain envelope `result_code = "update_required"` + (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`, and makes **zero** successful + requests. The gate **fails open** (an absent or unparseable header passes) and is **dormant** until + `GATEWAY_MIN_CLIENT_VERSION` is deliberately set (empty ⇒ off, so web behaviour is unchanged). The client + raises a terminal *update* overlay — native → the store listing (`VITE_RUSTORE_URL`), web → reload. +- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three + things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or + reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition` + code — never changes; (3) the FBS schema stays **additive** (append trailing fields; `(deprecated)`, never + delete — deleting shifts field IDs and breaks older readers). A breaking wire change happens only *inside* + the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that + ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same + rollout (see [`../deploy/README.md`](../deploy/README.md)). +- **Gate × offline** (UX rule): deliberate offline uses the network kill switch, so the gate never fires + while playing offline — an old install can always play local vs_ai / hotseat. The terminal *update* + overlay is raised **only on a user-initiated online action**; the silent background guest reconciliation + (§3) swallows an `update_required` and stays a local guest rather than interrupting local play. ## 3. Authentication & sessions @@ -224,6 +248,19 @@ arrive from a platform rather than completing a mandatory registration). `BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate. Platform and email users are auto-provisioned **durable** accounts with an identity. +- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play + identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated + id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first + launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local + games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest` + row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session + cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It + unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server + session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an + `update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2). + **Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity + transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the + prior online-session rule. > **Decision (2026-06-20) — single bot, preference-based variant gating.** The former > two-bot model (one bot per service language, with `accounts.service_language`, a @@ -1468,6 +1505,24 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test contour activates; the prod main host omits it. +**Native Android build (Capacitor).** The SPA is also packaged as a standalone **Android app** (Capacitor 8, +appId `ru.eruditgame.app`, "Эрудит"; minSdk 24, compile/targetSdk 36, JDK 21), first for **RuStore**. It is +a **bundle** model — the WebView loads the packaged `dist/` from app assets (**no `server.url`**), so there +is no OTA: updates ship through the store, and the **client-version gate** (§2) turns away a build too old to +speak the current wire contract. Because the packaged origin is `file://`, the native build talks to an +**absolute** gateway origin (`VITE_GATEWAY_URL` = the production origin; `lib/origin.ts` centralises how absolute URLs are built), and the service worker is skipped (the bundle is +the cache). It is **offline-first**: +`ui/scripts/bundle-dicts.mjs` copies the versioned `scrabble-dictionary` release DAWGs into +`dist/dict/@.dawg` (keyed on `VITE_DICT_VERSION`), so a cold first launch with no network +enters as a **local guest** (§3) and plays local vs_ai / hotseat from the bundled dictionaries. Purchases are +hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `workflow_dispatch` workflow +(`.gitea/workflows/android-build.yaml`, from `master`, `confirm=build`) that builds the native SPA, bundles +the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are +present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA` +(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor +with a host-provisioned Android SDK; RuStore upload is manual. Full plan + runbook: +[`../ANDROID_PLAN.md`](../ANDROID_PLAN.md), [`../deploy/README.md`](../deploy/README.md). + ## 14. CI & branches - **Two long-lived branches**: **`development`** is the integration diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 5da59fe..ac83c50 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -314,6 +314,19 @@ itself. A self-entered offline is temporary: it reverts to online the moment the the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that dialog — is the player's choice: it is kept, and never undone automatically. +### Native app (Android) +The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same +client. Everything it needs is bundled, so it opens with **no network**: a first launch offline drops you +straight into the lobby as a **guest** (no sign-in wall) and you can play right away — against the robot, or +a **pass-and-play** game with friends on one device — using the dictionaries shipped inside the app. When the +network is available the app quietly establishes a guest in the background and online features (random +opponents, friends, statistics) light up without interrupting play; local games you started offline stay on +the device. To keep a durable account you sign in with **email** from Profile (the guest becomes your +account); Telegram and VK sign-in are not offered in the native app. In-app purchases are hidden in this +first release. Because an installed app can be far older than the server, a build that is **too old** to talk +to the current server shows a single, non-dismissable *update* screen whose button opens the store listing — +this appears only on an online action, never while you are playing offline. + ### Social: friends, block, chat, nudge Become friends in two ways: redeem a **one-time code** the other player issues (six digits, valid for twelve hours), or send a **request to someone you have played diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index c39dd0c..fd41093 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -321,6 +321,20 @@ e-mail) либо ввод фразы. Активные игры форфейтя следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически. +### Нативное приложение (Android) +Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка +того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу +открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по +очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть +доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья, +статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы +сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом); +вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе +скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком +стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка +которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время +офлайн-игры. + ### Социальное: друзья, блок, чат, nudge Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку diff --git a/docs/TESTING.md b/docs/TESTING.md index 14c0eca..8419e7f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -31,7 +31,20 @@ tests or touching CI. build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui` CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the - production build. + production build. The **native offline-first** spec (`e2e/native.spec.ts`) injects + `window.androidBridge` (so `@capacitor/core` resolves the platform to `android` — a bare + `Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands + in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier + (`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives + `window.__native.reconcile()` to prove online lights up and Profile hides the Telegram/VK link buttons. + The **update overlay** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove the terminal + update cover; `retry.test.ts` covers the `FailedPrecondition → update_required` mapping. +- **Client-version gate** (Go) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH` parse + (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering; `connectsrv`'s server tests + assert a too-old `Execute` returns `result_code = "update_required"` (and the op handler never ran), a + too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min / unparseable header + / equal version all pass (fail-open); `config` tests reject a non-empty, unparseable + `GATEWAY_MIN_CLIENT_VERSION`. - **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared `ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a real self-played 35-move game) must rasterize to a plausible PNG @@ -175,6 +188,16 @@ tests or touching CI. `inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with delete / delete-all. The admin-console render test renders the feedback list + detail pages. +- **Native Android (manual on-device smoke)** — the packaged APK is verified by hand on a device / + emulator before release (the automated e2e covers the offline-first logic; WebView-specific chrome and + store behaviour are not reproducible in Playwright). Checklist: installs and cold-launches; in + **airplane mode** the cold launch lands in the **guest lobby**; play a local **vs_ai** move and a + **2-player hotseat** game with no network; the hardware **Back** button navigates then exits at the + root; a share / export link resolves to `erudit-game.ru` (not `file://`); turning the **network on** + lights up online play (a server guest is established); Profile offers **email** sign-in but no + Telegram / VK link; and with `GATEWAY_MIN_CLIENT_VERSION` bumped above the build, an online action + raises the terminal **update** overlay. Measure native chrome (safe-area / edge-to-edge) by CDP, not by + eyeballing a screenshot — an emulator WebView may be newer than a user's device (see `.claude/CLAUDE.md`). ## Principles diff --git a/ui/README.md b/ui/README.md index 87727a6..d3c71c4 100644 --- a/ui/README.md +++ b/ui/README.md @@ -31,6 +31,11 @@ enables the "Link Telegram" web sign-in (the Login Widget) — inert until the s domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the friend-invite Mini App link for the single bot (full URL `https://t.me//`). `VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page. +The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version +the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases +in the RuStore MVP), `VITE_RUSTORE_URL` (the update overlay's store target; empty until published) and +`VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by +`.gitea/workflows/android-build.yaml`; see `ANDROID_PLAN.md`. The build has **two entries**: the game SPA (`index.html`, served at `/app/` and `/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).