de003e862a
Make the web SPA behave correctly inside the Capacitor native shell, where the bundle loads from a local origin: - New lib/origin.ts gatewayOrigin(): absolute URLs resolve to VITE_GATEWAY_URL on native (the finished-game export/share URL in Game.svelte and the Wallet site-root link), falling back to the page origin on web. transport.ts already resolved via VITE_GATEWAY_URL and is left as-is. - Skip the PWA service worker on the native channel (the assets are already local; a worker would risk serving stale content across store updates). - Hide the money-purchase UI in the MVP: new distribution.purchasesHidden() folds VITE_PAYMENTS_DISABLED and the Google Play flag. The Wallet "buy" tab shows a neutral, pointer-free note (wallet.purchasesSoon) for the RuStore MVP and keeps the RuStore stub Google-Play-only. A ?nopay mock force mirrors ?gp for the e2e. - Native env types in vite-env.d.ts. Client-only, contour-safe: no wire/proto/schema change; web/VK/Telegram unchanged. Tests: origin + purchasesHidden unit tests, a ?nopay wallet e2e. svelte-check clean, vitest green, web + native vite build clean.
594 lines
38 KiB
Markdown
594 lines
38 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–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
|
||
|
||
#### 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:** provide a variant used by background guest-reconciliation that does
|
||
NOT call `reportUpdateRequired()` (it swallows the code and stays offline). Simplest: a boolean
|
||
on `exec`/a dedicated `authGuestSilent` path guarded by a flag; the executing session picks the
|
||
seam. Foreground `auth.*` keep the overlay behaviour.
|
||
- **`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:** `update.svelte.test.ts`; extend `retry.test.ts` (`FailedPrecondition → 'update_required'`);
|
||
Playwright `update.spec.ts` driving `__update.on()`.
|
||
|
||
**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` (the silent variant from C2), cache the session, adopt it; unlock online features.
|
||
Guard against duplicates (only when no cached session). Local games remain device-only. On
|
||
`update_required`, stay offline silently (no overlay).
|
||
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, 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.
|