a57fd355ba
Introduce a minimum-supported-client gate so a future incompatible wire change can turn away installed builds too old to speak it, cleanly, instead of letting them crash on decode. It rides the outermost stable layer (an HTTP header), never the FlatBuffers payload. Gateway: - New internal/clientver: dependency-free parse + compare of the leading MAJOR.MINOR.PATCH (a git-describe suffix is tolerated). - GATEWAY_MIN_CLIENT_VERSION config (empty => gate dormant; validated at load). - connectsrv checks the X-Client-Version header before decoding the payload: Execute returns result_code="update_required" (before the registry lookup), Subscribe returns FailedPrecondition. It fails open on an absent or garbled header — the header is a client-controlled compatibility signal, not an access control. Client: - Attach X-Client-Version on every call. - A terminal update.svelte.ts store + a non-dismissable UpdateOverlay (native opens VITE_STORE_URL, web reloads); retry.ts maps FailedPrecondition to the update_required sentinel; a mock __update hook drives the e2e. Wire-additive and contour-safe: no FBS/proto regen, no schema migration; the gate stays dormant until GATEWAY_MIN_CLIENT_VERSION is deliberately set, so web / VK / Telegram behaviour is unchanged. The silent reconciliation seam is deferred to the offline-first work (its only caller). Tests: Go clientver/config/connectsrv gate tests, retry.test.ts, Playwright update.spec.ts.
610 lines
39 KiB
Markdown
610 lines
39 KiB
Markdown
# Native Android (Capacitor) — RuStore MVP: bundle + client-version gate + offline-first
|
||
|
||
> Self-contained implementation plan for the standalone Android app, to be executed in a fresh
|
||
> session (another device). Work through the breakdown A–G in order; each part states its own
|
||
> "Done when". `PLAN.md` at the repo root is the precedent for a top-level plan doc here.
|
||
|
||
---
|
||
|
||
## Context (why)
|
||
|
||
`erudit-game.ru` ships today as a web SPA (Svelte + Vite) served by the gateway, plus Telegram and
|
||
VK Mini Apps. The owner wants a **standalone Android app**, first on **RuStore**. The stack already
|
||
declares Capacitor as the target and is pre-seamed for it (feature-detected `window.Capacitor`,
|
||
`file://`-safe hash router, relative asset base, a reserved native share branch,
|
||
`viewport-fit=cover`/safe-area layout, a build-time gateway origin var), but **nothing is
|
||
scaffolded** — no `@capacitor/*` dep, no `capacitor.config.*`, no `android/`.
|
||
|
||
Two problems native introduces that the web never had:
|
||
|
||
1. **An installed build can be arbitrarily old.** On web every load fetches the current client, so
|
||
client and server are always paired. In a store a user may run a months-old bundle; if the
|
||
FlatBuffers wire schema changes incompatibly, an old bundle cannot speak to the server at all.
|
||
Today there is **no** client↔server version contract — the only "update" path is the web-only
|
||
`location.reload()`, useless to a bundled APK. → build a **minimum-supported-client gate**.
|
||
|
||
2. **First launch must work with no network.** The app must open straight into a guest experience
|
||
and let the user play locally (vs_ai *and* 2-4-player pass-and-play / hotseat) even if the
|
||
internet is off on first launch. The current offline mode is a *returning-user* feature: it needs
|
||
a cached session/profile and server-fetched dictionaries (`offline.ts shouldBootOffline` requires
|
||
`hasSession && hasProfile`; dicts come from `gateway.fetchDict`). → build **offline-first**:
|
||
bundle dictionaries in the APK and boot as a device-local guest with no server session.
|
||
|
||
**Intended outcome:** a signed APK on RuStore that (a) loads the packaged SPA against the production
|
||
gateway, (b) is protected by the version gate so future incompatible server changes turn old
|
||
installs away cleanly, and (c) opens offline-first as a soft guest with local play.
|
||
|
||
## Locked decisions (owner interview)
|
||
|
||
| Decision | Choice |
|
||
|---|---|
|
||
| First store | **RuStore** (Google Play is a later variant — see below) |
|
||
| Update model | **Bundle** (`dist/` inside the APK) **+ a client-version gate** (no OTA, no remote-URL wrapper) |
|
||
| First-launch offline | **Offline-first in the MVP**: enter as a local guest, play vs_ai + hotseat with zero network |
|
||
| Login surface | Guest + email only (already the case — `Login.svelte` shows only these; VK/Telegram auth runs only inside their Mini Apps) |
|
||
| Payments in MVP | Hidden (deferred; reuse the distribution flag) |
|
||
| appId (permanent) | **`ru.eruditgame.app`** |
|
||
| App display name | **`Эрудит`** (Cyrillic) |
|
||
| Toolchain (locked) | **Capacitor 8** (`@capacitor/*` `^8`); **compileSdk/targetSdk 36, minSdk 24**; **JDK 21** (required — `@capacitor/android` compiles at Java 21; AGP 8.13 / Gradle 8.14.3); Android SDK `platforms;android-36` + `build-tools;36.x` |
|
||
|
||
**Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code,
|
||
commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo
|
||
rejects standalone spec artifacts, so `ANDROID_PLAN.md` is the only new plan file; do not create
|
||
`docs/superpowers/specs/*`. Branch model: `feature/android-native` from `development`, PR into
|
||
`development` (contour review), then promote `development → master`, tag, dispatch the Android build.
|
||
The gate change is **wire-additive and contour-safe** (a new HTTP header + a new envelope
|
||
`result_code` string; no FBS/proto regen, no schema migration). The offline-first change is
|
||
client-only (no server change).
|
||
|
||
---
|
||
|
||
## Progress (as-built)
|
||
|
||
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
|
||
|
||
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
|
||
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
|
||
`ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build` → `cap sync` → `./gradlew assembleDebug`
|
||
builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator.
|
||
`svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in
|
||
`.claude/CLAUDE.md` → "Native Android build (Capacitor)".
|
||
**Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э»
|
||
brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive
|
||
icons + splashes. Superseded by the icon rebrand below.
|
||
- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts`
|
||
`gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL,
|
||
`screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left
|
||
as-is. Service worker skipped on the native channel (`lib/pwa.svelte.ts`). Payments hidden in the MVP
|
||
via new `purchasesHidden()` (`lib/distribution.ts`: folds `VITE_PAYMENTS_DISABLED` + the GP flag, plus
|
||
a `?nopay` mock force); the `Wallet.svelte` buy tab shows a neutral pointer-free note
|
||
(`wallet.purchasesSoon`) for the RuStore MVP, RuStore stub kept GP-only. Native env types in
|
||
`vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean;
|
||
`?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch
|
||
browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs).
|
||
- **C. Client-version gate — ✅ DONE & verified (2026-07-12).** Server: new `gateway/internal/clientver`
|
||
(parse + compare), `GATEWAY_MIN_CLIENT_VERSION` config (empty ⇒ dormant, validated at load), and the
|
||
gate in `connectsrv` — `Execute` returns `result_code="update_required"` before the registry lookup,
|
||
`Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client:
|
||
`X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store +
|
||
`UpdateOverlay.svelte` (native → `VITE_STORE_URL`, web → reload), `retry.ts` maps
|
||
`FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go
|
||
`clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl.
|
||
`update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app
|
||
store-update SDK noted Out of scope.
|
||
- **D–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
|
||
the formats drift — `favicon.svg` is a rounded bordered tile, `icon-maskable-512.png` a full-bleed
|
||
square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable
|
||
(`capacitor-assets` insets it 16.7% into the adaptive safe zone). `~/.claude/bin/gitea-ci-watch.py` is present.
|
||
RuStore publication prerequisites gate release only.
|
||
|
||
---
|
||
|
||
## Prerequisites to start DEVELOPMENT (on the other device)
|
||
|
||
Have all of these before writing code:
|
||
|
||
- **JDK 21** (Temurin/OpenJDK) — **required** by Capacitor 8: `@capacitor/android` sets
|
||
`sourceCompatibility/targetCompatibility = VERSION_21`, so a JDK 17 Gradle run fails with
|
||
`invalid source release: 21`. (Homebrew: `brew install openjdk@21`.)
|
||
- **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or*
|
||
cmdline-tools. Capacitor 8 targets **compileSdk/targetSdk 36, minSdk 24**, so install:
|
||
`platform-tools`, `platforms;android-36`, `build-tools;36.0.0` (a newer build-tools such as
|
||
`36.1.0` is also accepted — AGP treats it as a minimum). Gradle 8.14.3 / AGP 8.13 arrive via the
|
||
wrapper `cap add android` generates.
|
||
- **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain.
|
||
- **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login
|
||
and the CI watcher exactly as the owner's global setup (`~/.claude` tooling:
|
||
`python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`).
|
||
- **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the
|
||
sibling `scrabble-solver` next to it — the solver **library** the backend/CI consume via the
|
||
`go.work` replace. **The bundled dictionaries do NOT come from the solver.** The versioned,
|
||
production dictionary set is published by `developer/scrabble-dictionary` as a **release artifact**
|
||
`scrabble-dawg-<DICT_VERSION>.tar.gz` (one semver label per set); `backend/Dockerfile` and every CI
|
||
job already `curl` exactly that tarball, keyed on the shared Gitea variable `DICT_VERSION`.
|
||
Offline-first bundles the DAWGs from that same release (see D.1 / E), so the Android build needs
|
||
`DICT_VERSION` + network access to the release, **not** `scrabble-solver`. (`scrabble-solver/dawg/*.dawg`
|
||
are the solver's committed test fixtures — byte-identical to a build but pinned to the solver
|
||
commit, not the versioned production set.)
|
||
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. a
|
||
Pixel AVD; any recent API image).
|
||
- **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway
|
||
is only needed to exercise the version gate locally (optional).
|
||
|
||
Publication prerequisites (RuStore account, keystore, listing assets) are listed at the **end** —
|
||
they gate *release*, not development.
|
||
|
||
---
|
||
|
||
## Identity model (answers "how do guests work offline vs in the DB")
|
||
|
||
Split the **local play identity** from the **server account**. This *extends* the current model
|
||
(guests are DB rows) with a pre-server local state; existing server-guest semantics are unchanged.
|
||
|
||
- **Local guest (device-local, no DB row).** A device-generated id + a default display name,
|
||
persisted on the device. Exists from the very first launch, with no network. It fills the human
|
||
seat in a local vs_ai game and is the "you" for local games. A purely-offline user never creates a
|
||
DB row (consumes no server resources).
|
||
- **Server guest (DB row).** Minted lazily via `auth.guest` the first time the app reaches the
|
||
network, its session cached and reused. Unlocks online features (matchmaking, friends, online
|
||
games). Exactly one per device — guarded by the cached session (never mint if one exists).
|
||
- **Registered account.** Email upgrade as today; adopts the current server guest.
|
||
- **Local games stay device-only.** Both local vs_ai and local hotseat games already persist only on
|
||
the device (`ui/src/lib/localgame/store.ts`); they never sync to the server, regardless of identity
|
||
transitions. Reconciliation to a server guest does **not** migrate them.
|
||
|
||
**Reconciliation flow:** on gaining network with no server session, silently `auth.guest` in the
|
||
background, cache the session, adopt it. This is best-effort — see the gate interaction below for how
|
||
a too-old client stays offline instead of being interrupted.
|
||
|
||
---
|
||
|
||
## The client-version gate — architecture (read before touching gate code)
|
||
|
||
**Principle: the version rides the outermost, permanently-stable layer, never the FlatBuffers
|
||
payload.** The transport is two layers: a protobuf Connect envelope
|
||
(`ExecuteRequest{message_type, payload, request_id}`, `gateway/proto/edge/v1/edge.proto`) wrapping a
|
||
FlatBuffers payload (`pkg/fbs/scrabble.fbs`). Protobuf envelopes and HTTP headers are
|
||
version-tolerant by design; the FBS payload is the layer that breaks. So the client version rides in
|
||
an **HTTP header `X-Client-Version`**, read by the gateway **before it decodes the payload**.
|
||
|
||
**Enforcement is per-call, not a dedicated init RPC.** The client attaches the header to every
|
||
Connect call via its single `headers()` builder; the gateway checks it at the top of `Execute`
|
||
(before registry lookup / auth / payload decode) and `Subscribe`. The first online call the app makes
|
||
(session establish `auth.*`) is thus gated automatically — no `Hello` RPC needed. A too-old client
|
||
makes **zero** successful requests but sees a recognizable signal, not a crash.
|
||
|
||
**Two return shapes, one meaning:**
|
||
- `Execute` → the domain-style envelope `result_code = "update_required"` (HTTP 200); the client
|
||
already throws `GatewayError(result_code)` for any non-`ok`.
|
||
- `Subscribe` → Connect `CodeFailedPrecondition` (a stream has no `result_code`).
|
||
|
||
**Frozen contract (write into `docs/ARCHITECTURE.md` §2):** three things become permanent so any
|
||
build, however old, can always recognize "update required": (1) the protobuf envelope field numbers
|
||
in `edge.proto` are never renumbered/reused; (2) the `update_required` sentinel (the `result_code`
|
||
string + the `FailedPrecondition` code) never changes; (3) the FBS schema stays **additive**
|
||
(trailing fields only, `(deprecated)` never delete). Breaking changes happen only *inside* the FBS
|
||
payload.
|
||
|
||
**Discipline (write into `deploy/README.md`):** the production deploy that ships an incompatible wire
|
||
change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same rollout. Until
|
||
deliberately set, `GATEWAY_MIN_CLIENT_VERSION` is **empty ⇒ the gate is dormant and web behaviour is
|
||
unchanged.** One hard threshold for the MVP; a soft "recommended update" tier is deferred.
|
||
|
||
**Interaction with offline-first (important UX rule):** offline mode uses the network kill switch
|
||
(`transport.ts assertOnline`), so the gate never fires while playing offline — an old client can
|
||
always play local vs_ai/hotseat. The gate matters only for online actions. Therefore the terminal
|
||
"update" overlay must be raised **only on a user-initiated online action**, never on the silent
|
||
background guest-reconciliation: if reconciliation's `auth.guest` returns `update_required`, swallow
|
||
it and stay a local guest (do not overlay). Implementation seam: the reconciliation path catches the
|
||
`update_required` code and does not route it to the global overlay trigger; foreground calls do.
|
||
|
||
---
|
||
|
||
## Work breakdown
|
||
|
||
Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence
|
||
is: scaffold → native-correct → gate → offline-first → CI → docs → release.
|
||
|
||
### A. Capacitor scaffolding — ✅ DONE
|
||
|
||
- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps
|
||
`@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`,
|
||
`"android:assets": "capacitor-assets generate --android"`.
|
||
- `ui/capacitor.config.ts` (new):
|
||
```ts
|
||
import type { CapacitorConfig } from '@capacitor/cli';
|
||
const config: CapacitorConfig = {
|
||
appId: 'ru.eruditgame.app',
|
||
appName: 'Эрудит',
|
||
webDir: 'dist',
|
||
// Bundle model: no server.url — the WebView loads the packaged dist/ from app assets. Updates
|
||
// ship through the store; the client-version gate turns away a build too old to speak the
|
||
// current wire contract (docs/ARCHITECTURE.md §2).
|
||
};
|
||
export default config;
|
||
```
|
||
- `npx cap add android` → generates and **commits** `ui/android/` (a Gradle project). Neither
|
||
`.gitignore` lists it today; keep it tracked so build.gradle + signing edits are reviewable and CI
|
||
reproducible. Ensure `ui/android/.gitignore` covers build outputs (`/app/build`, `/build`,
|
||
`/.gradle`, `/local.properties`, `*.keystore`, `*.jks`).
|
||
- **Android hardware Back** — new `ui/src/lib/native.ts`, dynamically importing `@capacitor/app` so
|
||
the web/mock bundle never pulls it:
|
||
```ts
|
||
import { clientChannel } from './channel';
|
||
export async function initNativeShell(atNavigationRoot: () => boolean): Promise<void> {
|
||
const ch = clientChannel();
|
||
if (ch !== 'android' && ch !== 'ios') return;
|
||
const { App } = await import('@capacitor/app');
|
||
App.addListener('backButton', () => {
|
||
if (atNavigationRoot()) void App.exitApp();
|
||
else history.back();
|
||
});
|
||
}
|
||
```
|
||
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
|
||
(mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`).
|
||
- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png`
|
||
(+ optional `splash.png`); `pnpm android:assets` generates adaptive icons.
|
||
|
||
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
|
||
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
|
||
|
||
### B. Native web-build correctness (file:// origin) — ✅ DONE
|
||
|
||
1. **One origin helper** — new `ui/src/lib/origin.ts`:
|
||
```ts
|
||
// The absolute origin the client talks to. A packaged native build (file:// origin) sets
|
||
// VITE_GATEWAY_URL to the real gateway; web/mock leave it empty and fall back to the page
|
||
// origin. Centralised so every absolute-URL construction resolves identically.
|
||
export function gatewayOrigin(): string {
|
||
const configured = import.meta.env.VITE_GATEWAY_URL ?? '';
|
||
return configured || (typeof location !== 'undefined' ? location.origin : '');
|
||
}
|
||
```
|
||
Fix the two same-origin **landmines** (they would produce `file:///…` on native):
|
||
- `ui/src/game/Game.svelte:1214`: `new URL(path, location.origin)` → `new URL(path, gatewayOrigin())`.
|
||
- `ui/src/screens/Wallet.svelte:47`: `` `${location.origin}/` `` → `` `${gatewayOrigin()}/` ``.
|
||
(`transport.ts:32` already computes the equivalent inline — leave it.)
|
||
2. **Skip the service worker on native** — `ui/src/lib/pwa.svelte.ts registerServiceWorker()` (line 85):
|
||
add, next to `if (inMiniApp()) return;`:
|
||
```ts
|
||
import { clientChannel } from './channel';
|
||
const ch = clientChannel();
|
||
if (ch === 'android' || ch === 'ios') return;
|
||
```
|
||
3. **Hide payments in the MVP build** — `ui/src/lib/distribution.ts`, add:
|
||
```ts
|
||
// purchasesHidden: true for the Google Play build (external-payment policy) OR the thin native
|
||
// MVP that defers store billing (VITE_PAYMENTS_DISABLED="1"). Every other build sells normally.
|
||
export function purchasesHidden(): boolean {
|
||
return isGooglePlayBuild() || (import.meta.env as Record<string, string | undefined>).VITE_PAYMENTS_DISABLED === '1';
|
||
}
|
||
```
|
||
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
|
||
hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub
|
||
`isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() &&
|
||
!isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key
|
||
`wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the
|
||
same note can replace the RuStore stub in the later Google Play anti-steering variant.
|
||
`purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the
|
||
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
|
||
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
|
||
4. **Native env matrix** — see Build & env.
|
||
|
||
**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the
|
||
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
||
|
||
### C. The client-version gate — ✅ DONE
|
||
|
||
#### C1. Backend (gateway)
|
||
|
||
- **New package `gateway/internal/clientver`** (`clientver.go` + `_test.go`): dependency-free parse of
|
||
the leading `v?MAJOR.MINOR.PATCH` (ignore any `-N-gSHA`/`+meta` suffix) + compare:
|
||
```go
|
||
package clientver
|
||
import ("strconv"; "strings")
|
||
type Version struct{ Major, Minor, Patch int }
|
||
func Parse(s string) (Version, bool) {
|
||
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||
if i := strings.IndexAny(s, "-+"); i >= 0 { s = s[:i] }
|
||
p := strings.SplitN(s, ".", 4)
|
||
if len(p) < 3 { return Version{}, false }
|
||
var v Version; var err error
|
||
if v.Major, err = strconv.Atoi(p[0]); err != nil { return Version{}, false }
|
||
if v.Minor, err = strconv.Atoi(p[1]); err != nil { return Version{}, false }
|
||
if v.Patch, err = strconv.Atoi(p[2]); err != nil { return Version{}, false }
|
||
return v, true
|
||
}
|
||
func Less(a, b Version) bool {
|
||
if a.Major != b.Major { return a.Major < b.Major }
|
||
if a.Minor != b.Minor { return a.Minor < b.Minor }
|
||
return a.Patch < b.Patch
|
||
}
|
||
```
|
||
Tests: `"v1.16.0"`, `"1.16.0"`, `"v1.16.0-3-gabc"`, `"dev"`/`""`→!ok, ordering incl. equal.
|
||
- **Config `gateway/internal/config/config.go`**: add `MinClientVersion string` (doc: empty ⇒ gate
|
||
off); `Load()`: `c.MinClientVersion = os.Getenv("GATEWAY_MIN_CLIENT_VERSION")`; `validate()`: if
|
||
non-empty and `!clientver.Parse(...ok)` → return a config error. Extend `config_test.go`.
|
||
- **Server `gateway/internal/connectsrv/server.go`**:
|
||
- consts `clientVersionHeader = "X-Client-Version"`, `resultUpdateRequired = "update_required"`;
|
||
`errors.go`: `errUpdateRequired = errors.New("client too old, update required")`.
|
||
- `Server`: add `minClient clientver.Version` + `gateOn bool`. `Deps`: add `MinClientVersion string`.
|
||
`NewServer`: parse `d.MinClientVersion` once (set `gateOn` on success; `log.Warn` + off if
|
||
unparseable).
|
||
- helper `clientTooOld(header string) bool`: `if !s.gateOn { return false }`; parse header, on
|
||
`!ok` return false (fail-open); return `clientver.Less(v, s.minClient)`.
|
||
- `Execute`: insert **after** the `defer` metrics line (after `server.go:296`, before
|
||
`registry.Lookup`), so `msgType` is set and the payload is untouched:
|
||
```go
|
||
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
|
||
result = resultUpdateRequired
|
||
return connect.NewResponse(&edgev1.ExecuteResponse{
|
||
RequestId: req.Msg.GetRequestId(), ResultCode: resultUpdateRequired,
|
||
}), nil
|
||
}
|
||
```
|
||
- `Subscribe`: at the top (before `resolve`): `if s.clientTooOld(req.Header().Get(clientVersionHeader)) { return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired) }`.
|
||
- `main.go`: add `MinClientVersion: cfg.MinClientVersion,` to the `connectsrv.Deps{...}` literal.
|
||
- Tests (`server_test.go`): too-old Execute ⇒ `result_code == "update_required"` and the op handler
|
||
never ran; too-old Subscribe ⇒ `FailedPrecondition`; absent header / empty min / unparseable
|
||
header / equal version ⇒ pass.
|
||
|
||
#### C2. Client (ui)
|
||
|
||
- **`ui/src/lib/update.svelte.ts`** (new, terminal — no poll):
|
||
```ts
|
||
export const UPDATE_REQUIRED = 'update_required';
|
||
let required = $state(false);
|
||
export const updateRequired = { get active(): boolean { return required; } };
|
||
export function reportUpdateRequired(): void { required = true; }
|
||
```
|
||
- **`ui/src/lib/transport.ts`**:
|
||
- `headers()` (line 37) always attaches the version header:
|
||
```ts
|
||
const headers = (): Record<string, string> => {
|
||
const h: Record<string, string> = { 'x-client-version': __APP_VERSION__ };
|
||
if (token) h.authorization = `Bearer ${token}`;
|
||
return h;
|
||
};
|
||
```
|
||
- result-code branch (line 86): `if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired();`
|
||
before the throw.
|
||
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
|
||
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
|
||
- **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant
|
||
(swallows `update_required` without raising the overlay) has no caller until D.4, so it is added
|
||
there with its caller rather than speculatively in C. In C every foreground call that gets
|
||
`update_required` raises the overlay; offline play never trips it (the network kill switch).
|
||
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
|
||
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
|
||
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
|
||
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
|
||
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys
|
||
`update.title/body/action` (add siblings to the maintenance keys).
|
||
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
|
||
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
|
||
- **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via
|
||
Playwright `update.spec.ts` (`__update.on()`). The `update.svelte.ts` store is a `$state` rune module,
|
||
which this project's plugin-less `vitest` cannot import (`$state is not defined`); like every other
|
||
`*.svelte.ts` store it is covered by the e2e, not a unit test.
|
||
|
||
**Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local
|
||
gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
|
||
|
||
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation)
|
||
|
||
Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player
|
||
hotseat (pass-and-play with a host PIN referee) — see `ui/src/lib/localgame/source.hotseat.test.ts`
|
||
and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch
|
||
with no network**.
|
||
|
||
1. **Bundle dictionaries in the APK.**
|
||
- New build step `ui/scripts/bundle-dicts.mjs`: copy the three DAWGs from the unpacked
|
||
**`scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz` → a dict dir passed via
|
||
`DICT_DIR`; **NOT** `../scrabble-solver/dawg`) → `ui/dist/dict/<dictKey>.dawg` (the
|
||
`dictKey(variant, version)` naming the loader's bundled tier fetches). Run only in the native
|
||
pipeline (after `pnpm build`, before `cap sync`). Web builds stay slim. `VITE_DICT_VERSION` must
|
||
equal the release's `DICT_VERSION` so the bundled `(variant, version)` matches what the loader
|
||
requests.
|
||
- Vite `define`: add `__DICT_VERSION__` from `VITE_DICT_VERSION` (mirrors `__APP_VERSION__`);
|
||
declare it in `vite-env.d.ts`.
|
||
- **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: between the IndexedDB tier (line 73-85)
|
||
and the network tier (line 87), add a **bundled tier** that `fetch('./dict/'+key+'.dawg')`
|
||
(relative — served from app assets on native, 404 on web → falls through). On a hit, build the
|
||
`Dawg`, cache in memory, seed IndexedDB (`idbPutDawg`), and return. This is the only change that
|
||
lets a never-online device obtain a dictionary.
|
||
- Offline local-game creation must request the **bundled version**: where `NewGame.svelte`
|
||
offline-create and `localgame` resolve the dict version, use `__DICT_VERSION__` so the requested
|
||
`(variant, version)` matches the bundled file (grep the offline `localSource.create` calls in
|
||
`NewGame.svelte:83-96,289-296` and the dict-version resolution in `gamesource.ts`/`localgame`).
|
||
2. **Local-guest identity** — new `ui/src/lib/localguest.ts`: a device-local id + default display
|
||
name (e.g. localized "Гость"), persisted (IndexedDB `scrabble` DB / localStorage). Used as the
|
||
human seat's account id in a local vs_ai game when there is no server session (today that id comes
|
||
from the session — see `localgame/source.list.test.ts:64`). Hotseat seats stay independent local
|
||
identities (already so).
|
||
3. **Cold offline-first boot** — `ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`:
|
||
- Extend `shouldBootOffline` (offline.ts:85) so that on a **native** channel, no session may still
|
||
boot offline as the local guest (dicts are bundled). Keep the current web rule intact (web still
|
||
needs a prior online session).
|
||
- In `bootstrap()` (app.svelte.ts): when native and (no cached session) and (offline preference on
|
||
OR the network is unreachable at boot), set `setOfflineMode(true, …)`, establish the local guest,
|
||
preload the bundled dicts, and land in the **lobby** (not `Login`). Preserve the existing online
|
||
path (guest/email) when reachable.
|
||
- `offlinePreloadEligible` (offline.ts:69): treat a native channel as eligible (drop the
|
||
`standalone && hasEmail` web gate for native), or bypass preload entirely on native since the
|
||
dicts are bundled.
|
||
4. **Lazy server-guest reconciliation** — when native and online and no server session: background
|
||
`auth.guest`, cache the session, adopt it; unlock online features. Guard against duplicates (only
|
||
when no cached session). Local games remain device-only. **Add the silent update-path variant here**
|
||
(a flag on the transport `exec`/a dedicated silent `auth.guest`) so reconciliation swallows
|
||
`update_required` and stays offline — no overlay. This is the seam deferred from C.
|
||
5. **Ensure both offline modes render for the local guest.** `NewGame.svelte` gates the offline
|
||
flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both
|
||
the "quick" (vs_ai) and "with friends" (hotseat) flows show. Verify the vs_ai human seat uses the
|
||
local-guest id.
|
||
6. **Soft registration** stays: the local/server guest can register by email → upgrade (existing
|
||
flow), adopting the server guest first if present.
|
||
|
||
**Tests:** extend the local-game/offline unit tests for the bundled-dict tier (a `fetch` mock
|
||
returning a blob) and the local-guest boot decision (`shouldBootOffline` native-no-session); a
|
||
Playwright offline-first spec that boots with the network hook off and plays a local vs_ai move and
|
||
starts a hotseat game. (Follow `docs/TESTING.md` layers; the mock e2e bypasses the codec, so keep the
|
||
gate's server path in Go tests.)
|
||
|
||
**Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and
|
||
plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on
|
||
silently establishes a server guest and lights up online play.
|
||
|
||
### E. CI — signed APK artifact
|
||
|
||
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
|
||
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
|
||
1. Checkout this repo; copy the Node/pnpm setup block from `.gitea/workflows/ci.yaml` (the `ui` job).
|
||
Add the **"Fetch dictionary DAWGs"** step verbatim from `ci.yaml` (the `curl …
|
||
scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz` +
|
||
untar into `${GITHUB_WORKSPACE}/dawg`), keyed on the `DICT_VERSION` Gitea variable — **this**, not
|
||
the `scrabble-solver` sibling, is the source of the bundled dicts. (`scrabble-solver` is not needed
|
||
by the Android build: `ui` is a Node project outside `go.work`.)
|
||
2. `pnpm install --frozen-lockfile`.
|
||
3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`,
|
||
`VITE_DICT_VERSION` = the release dict version.
|
||
4. `DICT_DIR=${GITHUB_WORKSPACE}/dawg node ui/scripts/bundle-dicts.mjs` (copy the release `.dawg`
|
||
files into `ui/dist/dict/`).
|
||
5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21).
|
||
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`,
|
||
`build-tools;36.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
|
||
7. `npx cap sync android`.
|
||
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
|
||
tag (scheme below).
|
||
9. `cd ui/android && ./gradlew assembleRelease -PversionCode=$CODE -PversionName=$NAME` with the
|
||
signing config reading the keystore + `ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/
|
||
`ANDROID_KEY_PASSWORD` from env.
|
||
10. Upload `ui/android/app/build/outputs/apk/release/*.apk` as an artifact.
|
||
|
||
Watch to green with `python3 ~/.claude/bin/gitea-ci-watch.py` (background). RuStore upload is manual
|
||
for the MVP.
|
||
|
||
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
|
||
`versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`.
|
||
Wire in `ui/android/app/build.gradle` `defaultConfig`:
|
||
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`,
|
||
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release`
|
||
reading env/props (guarded so a local `assembleDebug` still builds unsigned).
|
||
|
||
### F. Docs (bake in the same PR)
|
||
|
||
- `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 /
|
||
reconciliation) and the **native Android build** (Capacitor bundle model, bundled dicts,
|
||
`versionCode` scheme, RuStore, `file://`-origin implications).
|
||
- `docs/FUNCTIONAL.md` (+ `docs/FUNCTIONAL_ru.md` mirror): user stories — offline-first guest launch
|
||
(vs_ai + hotseat with no network), soft registration, "update required when too old", native
|
||
distribution (no purchases in the MVP).
|
||
- `docs/TESTING.md`: the new Go tests (`clientver`, gate), the client tests, the bundled-dict tier
|
||
test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to
|
||
guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway;
|
||
network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped).
|
||
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`,
|
||
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value.
|
||
- `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore
|
||
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
|
||
ships an incompatible wire change.
|
||
|
||
### G. Release + owner handoff
|
||
|
||
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
|
||
2. Promote `development` → `master`; tag `vX.Y.0`.
|
||
3. Dispatch `android-build`; watch green; retrieve the signed APK.
|
||
4. Owner runs the on-device smoke checklist (incl. airplane-mode first launch).
|
||
5. Owner uploads to RuStore (see publication prerequisites).
|
||
|
||
---
|
||
|
||
## Google Play variant (later — design now so we don't repaint)
|
||
|
||
One codebase, two build variants selected by flags:
|
||
- **RuStore build** (this MVP): `VITE_PAYMENTS_DISABLED=1` (MVP), `VITE_GP_BUILD` unset. Later, when
|
||
RuStore billing lands, RuStore sells normally.
|
||
- **Google Play build** (later): `VITE_GP_BUILD=1` ⇒ `purchasesHidden()` true. The **only** functional
|
||
difference is that purchases are hidden — the flag structure (`purchasesHidden()`) already carries
|
||
both cases, so no repaint is needed.
|
||
|
||
**Google Play compliance — decided: no RuStore steering.** Google forbids not just external billing
|
||
but *steering* users to other payment methods or app stores. So the Google Play build hides purchases
|
||
with **no** pointer to RuStore: the existing `isGooglePlayBuild()` RuStore-pointer stub is **dropped**
|
||
in the GP variant (show nothing, or a neutral pointer-free message — never name or link RuStore or any
|
||
other store). This does not affect the RuStore MVP (there `isGooglePlayBuild()` is false, so the stub
|
||
never shows). Google Play also requires an **AAB** (not APK) and Play App Signing — a separate CI
|
||
target, built later.
|
||
|
||
---
|
||
|
||
## Build & env matrix
|
||
|
||
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
|
||
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var).
|
||
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target.
|
||
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header.
|
||
- `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests.
|
||
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
|
||
|
||
**Web/contour/prod builds:** unchanged. `VITE_GATEWAY_URL` empty (same-origin),
|
||
`GATEWAY_MIN_CLIENT_VERSION` unset (gate dormant). Nothing about web behaviour changes until a future
|
||
breaking release deliberately sets the min version.
|
||
|
||
---
|
||
|
||
## Prerequisites to start PUBLICATION (RuStore)
|
||
|
||
- **RuStore developer account** — a registered legal entity or self-employed (самозанятый) RU status,
|
||
verified. Gates publication (not the build).
|
||
- **Release keystore** — generate once and **back up immutably** (losing it means never updating this
|
||
app again):
|
||
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`.
|
||
Set Gitea repo secrets: `ANDROID_KEYSTORE_BASE64` (base64 of the .jks),
|
||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
|
||
- **The signed APK** — from the `android-build` artifact.
|
||
- **Store listing** — reserve `ru.eruditgame.app`; title «Эрудит»; RU description; phone screenshots;
|
||
icon/feature graphic; category; age rating; a hosted **privacy-policy URL**; the RuStore content
|
||
declaration.
|
||
- (For the later **Google Play** variant: a Google Play Console account ($25), the GP build (payments
|
||
hidden + the anti-steering fix), an **AAB**, Play App Signing, and the Play Data-safety form.)
|
||
|
||
## Risks & mitigations
|
||
|
||
- **Keystore loss** → app can never be updated. Back up off-host immediately.
|
||
- **Gate dormant until first bump** → its real firing only happens on a future breaking release. The
|
||
Go `server_test.go` tests + the Playwright `__update` e2e prove the whole path now.
|
||
- **Update overlay interrupting offline play** → the gate×offline rule (overlay only on user-initiated
|
||
online actions; silent reconciliation swallows `update_required`).
|
||
- **`X-Client-Version` stripped by the edge** → Caddy forwards request headers by default (the RPC
|
||
route already reaches the gateway); no Caddyfile change. Confirm via the smoke (bump min → overlay).
|
||
- **APK size from bundled dicts** → a few MB per variant; acceptable. Could trim to RU-only later.
|
||
- **RuStore account gate** (legal status) → surface to the owner before release.
|
||
|
||
## Out of scope (deferred)
|
||
|
||
Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app
|
||
purchases in the native build (RuStore billing SDK) and the GP anti-steering fix, VK/Telegram login in
|
||
the native build, a soft "recommended update" tier, **in-app store-update flows** (Google Play In-App
|
||
Updates API / RuStore In-App Update SDK — a future enhancement that would swap the update overlay's
|
||
action from opening the store listing to a store-driven immediate in-app update; needs a Capacitor
|
||
plugin bridge, and is orthogonal to the server-driven gate), native splash/status-bar plugins, push
|
||
notifications (FCM), and automated RuStore-API upload.
|
||
|
||
## End-to-end verification
|
||
|
||
- **Unit/integration:** `go test ./gateway/...` (clientver + gate), `cd ui && pnpm check &&
|
||
pnpm test:unit && pnpm test:e2e`, `gofmt -l .` clean, `go vet ./gateway/...`.
|
||
- **Gate proof:** gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` ⇒ overlay; unset ⇒ normal.
|
||
- **Offline-first proof:** native `assembleDebug`, install, **airplane mode**, cold launch → guest
|
||
lobby → play a local vs_ai move and a 2-player hotseat game; network on → server guest established.
|
||
- **Native build proof:** guest sign-in online, a move, Back navigates then exits at root, a
|
||
share/export link points at `erudit-game.ru`, purchases absent.
|
||
- **Signed pipeline:** dispatch `android-build`; watcher green; the release APK installs and launches.
|
||
- **Contour:** the `feature/android-native` PR deploys cleanly (no schema/wire regen; gate dormant);
|
||
web/VK/TG behaviour unchanged.
|