Merge pull request 'feat(offline): implicit net-state model, two-tier version gate, unified lobby' (#249) from feature/android-native into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m13s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped

This commit was merged in pull request #249.
This commit is contained in:
2026-07-13 00:59:16 +00:00
161 changed files with 7795 additions and 934 deletions
+65
View File
@@ -28,6 +28,57 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack - **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`. pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
## Native Android build (Capacitor)
The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:
- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
`brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
`/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
(`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
`android-36.1` does NOT satisfy compileSdk 36.
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
--cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
→ `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
(dodges the corepack flake).
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
## Wire / schema evolution (client ↔ gateway ↔ backend) ## Wire / schema evolution (client ↔ gateway ↔ backend)
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never - **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
@@ -84,6 +135,20 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
eyeball per-variant tiles. eyeball per-variant tiles.
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a - **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
plugin-config gotcha in wiring them). plugin-config gotcha in wiring them).
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env). - **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
Use **testcontainers** for container-backed tests. Use **testcontainers** for container-backed tests.
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600) - **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
@@ -0,0 +1,172 @@
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
#
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and
# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the
# runner host needs nothing pre-installed.
name: android-build
run-name: "android build ${{ github.sha }}"
on:
workflow_dispatch:
inputs:
confirm:
description: 'Type "build" to confirm an APK build from master.'
required: true
default: ""
permissions:
contents: read
env:
NO_COLOR: "1"
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
DICT_VERSION: ${{ vars.DICT_VERSION }}
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
VITE_PAYMENTS_DISABLED: "1"
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
# lives elsewhere (the default matches deploy/README.md's install path).
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
jobs:
build:
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
# missing — this doubles as the runner-access check (deploy/README.md).
- name: Verify the host Android SDK
run: |
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sm" ]; then
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
fi
for pkg in "platforms/android-36" "build-tools"; do
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
fi
done
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
- name: Compute version + native build env
id: prep
env:
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
run: |
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
# rather than derive a versionCode from a "-N-gSHA" describe.
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
case "$desc" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
esac
v="${desc#v}"
IFS=. read -r MA MI PA <<< "$v"
# 10# forces base-10 so a zero-padded part is never read as octal.
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
# trailing slash so the Connect endpoint never doubles it.
gateway="${PUBLIC_BASE_URL%/}"
{
echo "tag=$desc"
echo "name=$v"
echo "code=$code"
echo "gateway=$gateway"
} >> "$GITHUB_OUTPUT"
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Build the SPA (native flavour)
working-directory: ui
env:
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
run: pnpm run build
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
# (after the build, before cap sync copies dist/ into the native assets).
- name: Bundle the dictionaries
working-directory: ui
env:
DICT_DIR: ${{ github.workspace }}/dawg
run: node scripts/bundle-dicts.mjs
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
- name: Sync the native project
working-directory: ui
run: node_modules/.bin/cap sync android
- name: Decode the release keystore
id: keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
echo "keystore decoded -> signed release build"
else
echo "file=" >> "$GITHUB_OUTPUT"
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
fi
- name: Assemble the release APK
working-directory: ui/android
env:
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
- name: Upload the APK artifact
uses: actions/upload-artifact@v4
with:
name: erudit-${{ steps.prep.outputs.name }}-apk
path: ui/android/app/build/outputs/apk/release/*.apk
if-no-files-found: error
+5
View File
@@ -353,6 +353,11 @@ jobs:
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini # for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. One VK ID "Web" app serves every contour -> unprefixed secret. # App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on # Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off. # test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
+6
View File
@@ -112,6 +112,12 @@ jobs:
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. # derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm. # Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh. # Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
+665 -83
View File
@@ -45,6 +45,7 @@ installs away cleanly, and (c) opens offline-first as a soft guest with local pl
| Payments in MVP | Hidden (deferred; reuse the distribution flag) | | Payments in MVP | Hidden (deferred; reuse the distribution flag) |
| appId (permanent) | **`ru.eruditgame.app`** | | appId (permanent) | **`ru.eruditgame.app`** |
| App display name | **`Эрудит`** (Cyrillic) | | 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, **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 commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo
@@ -57,23 +58,166 @@ 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_RUSTORE_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. Offline-first — ✅ CODE-COMPLETE, e2e-verified & on-device-smoke-verified (D.1D.6 done, 2026-07-12).**
The offline path is proven on-device (Pixel_10 / API 37, airplane mode): cold-boot → offline guest lobby, a
full local vs_ai turn (bundled dawg — Hint placed "FEZ", the robot replied "NEEDFIRE"), New Game offering
both modes; the smoke also surfaced + fixed a native-chrome edge-to-edge safe-area bug (below). Remaining for
D: the **reconcile-online leg on-device** (deferred — it hits prod, minting a guest) and the **deferred
local-game-visibility decision** (below). D.5 is also covered by `e2e/native.spec.ts` (vs_ai move + hotseat).
- **D.1 + D.2 (foundations) — ✅ DONE & committed `bcd5a1d` (2026-07-12).** `ui/scripts/bundle-dicts.mjs`
(release DAWGs → `dist/dict/<variant>@<version>.dawg`, keyed on `VITE_DICT_VERSION`, `OUT_DIR` override
for the e2e); the `dict/loader.ts` **bundled tier** (between IndexedDB and network, **native-gated**
see the §D.1 correction); `__DICT_VERSION__` vite define + declaration; `lib/localguest.ts` (persisted
device-local guest id, no DB row) + `common.guest` i18n; `NewGame.svelte` offline vs_ai/hotseat creates
fall back to `__DICT_VERSION__` and `localGuestId()`/`t('common.guest')` when there is no session.
- **D.3 (cold boot) + D.4 (reconciliation + silent seam) — ✅ DONE & verified (2026-07-12).** The native
no-session cold boot lands in the offline lobby as a device-local guest (`app.svelte.ts` bootstrap
`else if (native)` branch — `localGuestId()` + `setOfflineMode(true,false)` + `scheduleRecovery(0)`);
lazy `reconcileServerGuest()` mints + adopts a server guest and clears the auto-offline when the gateway
is reachable (kicked at boot + by the recovery poll + the online event). Silent seam:
`transport.ts` `exec` gained `{ silent, allowOffline }` (suppress the update overlay **and** bypass the
kill switch), plus `authGuestSilent(locale)` on the `GatewayClient` interface, the real transport and the
mock. `native.ts` `initNativeShell` made bridge-tolerant. New `e2e/native.spec.ts` (Capacitor injected;
boot→offline-lobby, local vs_ai move, hotseat start, reconcile→online) + `playwright.config.ts` bundles
the dawgs into `dist-e2e/dict/`. `svelte-check` 0, `vitest` 591, web + native builds clean; e2e runs in CI.
See §D.3/§D.4 for the as-built corrections (shouldBootOffline **not** changed; `allowOffline` +
checkReachable-token findings; the `__native.reconcile` e2e hook).
- **D.6 (Profile tg/vk hide on native) — ✅ DONE & verified (2026-07-12).** `Profile.svelte` gates
`telegramLinkable`/`vkLinkable` on `!nativeShell` (`clientChannel()` android/ios), so the Telegram + VK
LINK buttons are hidden on the native build; email + account management (incl. any existing link's UNLINK,
a redirect-free gateway call) stay. Covered by the `e2e/native.spec.ts` reconcile test (Profile → no Link
Telegram / Link VK buttons, email present).
- **Native chrome: Android 15+ edge-to-edge safe-area — ✅ FIXED & on-device-verified (2026-07-12, owner-confirmed).**
targetSdk 36 forces edge-to-edge, so the WebView draws behind the system bars; the app chrome overlapped the
status bar (top nav, untappable) and the gesture-nav home indicator (the game's centre Hint button — side
buttons fine). Two-part CSS fix consuming the SystemBars core plugin's injected `--safe-area-inset-*`:
the **bottom** via the `--tg-safe-*` token (`app.css`), the **header top** via a native-only `.bar` rule
(`Header.svelte` — its top inset had been Telegram-fullscreen-scoped only, so the token alone didn't reach
it). No dep/config (SystemBars ships in `@capacitor/core` v8). Verified on Pixel_10 / API 37 by live-WebView
CDP measurement (header title y=11→65, `--safe-area-inset-*` = 54/24px) **and** owner re-test on their device.
Full gotcha + CDP recipe in `.claude/CLAUDE.md`. Commits `4a0689a` (bottom) + `49c5379` (top).
- **DEFERRED DECISION (owner-agreed 2026-07-12) — native local-game visibility.** The online lobby lists
only server games (`Lobby.svelte:42/54`), so a native guest's **device-local vs_ai/hotseat games hide from
the lobby once reconciled online** (the pre-existing offline↔online split). For native this IS the primary
flow, so the behaviour **must change** — a native guest should still see / resume their local games when
online. Owner agreed it needs changing but wants to design it **at the very end of the Android work (before
release — see §G)**; out of D.3/D.4 scope, tracked here so it is not lost.
- **E. CI — signed APK artifact — ✅ CODE-COMPLETE & locally-proven (2026-07-12).** New manual workflow
`.gitea/workflows/android-build.yaml` (`workflow_dispatch` + `confirm=build`, `if` gated to `master`
mirrors `prod-deploy.yaml`; never on a PR) builds the native-flavoured SPA, bundles the offline dicts, and
assembles a release APK uploaded as a run artifact. Like `prod-deploy`, it is **provable only by a dispatch
from `master`** (that is §G), so E was verified as far as possible now: locally via `assembleDebug` + a
**keyless** `assembleRelease` (→ `app-release-unsigned.apk`, `versionCode 1017000` / `versionName 1.17.0`
for a `v1.17.0` tag — confirmed in `output-metadata.json`), `svelte-check` 0, YAML structure checked.
As-built:
- **`ui/android/app/build.gradle`:** `versionCode`/`versionName` read `-PversionCode`/`-PversionName` (defaults
keep local `assembleDebug` building). **CORRECTION vs the plan snippet — use `=` assignment**
(`versionCode = (…).toInteger()`), NOT the command form `versionCode (…).toInteger()`: the latter binds
`.toInteger()` to the DSL setter's null return (`> Value is null`). A guarded `signingConfigs.release` reads
the keystore from env and is attached **only when the keystore file exists** — so **no keystore ⇒ an UNSIGNED
release APK, not a failure** (a dispatch proves the pipeline before the keystore exists).
- **Toolchain:** the **CI runner is a host-executor on a remote Debian host**. JDK 21 comes from
`actions/setup-java`; the **Android SDK is host-provisioned** (pre-installed at `ANDROID_SDK_DIR`, default
`/opt/android-sdk`), with a fail-fast **Verify the host Android SDK** pre-flight that also checks the `runner`
user's read/exec access (`platforms;android-36` + `build-tools`). Unit tests need no new tooling (they ride
the existing `unit`/`ui` jobs).
- **Env:** `VITE_GATEWAY_URL` reuses `vars.PROD_PUBLIC_BASE_URL` (trailing slash stripped); `VITE_DICT_VERSION` /
`DICT_VERSION` = the `DICT_VERSION` var; `VITE_PAYMENTS_DISABLED=1`. **`VITE_STORE_URL` was renamed to
`VITE_RUSTORE_URL`** (owner) — left **empty** via the (unset) `vars.ANDROID_RUSTORE_URL`, so the update button
no-ops until publication (`UpdateOverlay.svelte` already guards `if (url)`; the gate is dormant in the MVP).
- **versionCode from an exact tag:** the workflow refuses anything but a clean `git describe --exact-match`
`vX.Y.Z` (deterministic + monotonic store versionCode); §G tags before dispatch.
- **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. 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; gated on the offline-model redesign (was G-step-0).** The deferred native
local-game-visibility decision grew into an owner-approved cross-cutting change (web + native + a small
additive backend/wire bit): remove the explicit offline toggle → one `netState` machine, unify the lobby,
two-tier version gate. **Designed + staged (O1O7)** in the "Offline-model redesign" section below.
**➡ Next actionable work: implement O1** (the pure `netState` reducer, test-first) via `stage-implementation`,
then O2O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of
O1O7 is started. Branch `feature/android-native`, local/unpushed.
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) ## Prerequisites to start DEVELOPMENT (on the other device)
Have all of these before writing code: Have all of these before writing code:
- **JDK 17** (Temurin/OpenJDK). - **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* - **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or*
cmdline-tools. Install: `platform-tools`, `platforms;android-34`, `build-tools;34.0.0`. 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. - **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 - **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: and the CI watcher exactly as the owner's global setup (`~/.claude` tooling:
`python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`). `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 - **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the
sibling `scrabble-solver` next to it. The sibling holds the committed dictionaries sibling `scrabble-solver` next to it — the solver **library** the backend/CI consume via the
(`scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg`) that offline-first bundles, and `go.work` replace. **The bundled dictionaries do NOT come from the solver.** The versioned,
the solver library the backend/CI use. production dictionary set is published by `developer/scrabble-dictionary` as a **release artifact**
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. Pixel, `scrabble-dawg-<DICT_VERSION>.tar.gz` (one semver label per set); `backend/Dockerfile` and every CI
API 34). 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 - **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). is only needed to exercise the version gate locally (optional).
@@ -152,7 +296,7 @@ it and stay a local guest (do not overlay). Implementation seam: the reconciliat
Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence 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. is: scaffold → native-correct → gate → offline-first → CI → docs → release.
### A. Capacitor scaffolding ### A. Capacitor scaffolding — ✅ DONE
- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps - `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps
`@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`, `@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`,
@@ -196,7 +340,7 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`) **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. `./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
### B. Native web-build correctness (file:// origin) ### B. Native web-build correctness (file:// origin) — ✅ DONE
1. **One origin helper** — new `ui/src/lib/origin.ts`: 1. **One origin helper** — new `ui/src/lib/origin.ts`:
```ts ```ts
@@ -228,15 +372,20 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
} }
``` ```
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
hide the buy actions (grep `isGooglePlayBuild` under `ui/src`). Keep the "go to RuStore" stub hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub
`isGooglePlayBuild()`-only. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`, `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_RUSTORE_URL?: string`,
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`. `VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
4. **Native env matrix** — see Build & env. 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 **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. purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
### C. The client-version gate ### C. The client-version gate — ✅ DONE
#### C1. Backend (gateway) #### C1. Backend (gateway)
@@ -313,96 +462,194 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
before the throw. before the throw.
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is - catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`. `UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
- **Reconciliation exception:** provide a variant used by background guest-reconciliation that does - **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant
NOT call `reportUpdateRequired()` (it swallows the code and stays offline). Simplest: a boolean (swallows `update_required` without raising the overlay) has no caller until D.4, so it is added
on `exec`/a dedicated `authGuestSilent` path guarded by a flag; the executing session picks the there with its caller rather than speculatively in C. In C every foreground call that gets
seam. Foreground `auth.*` keep the overlay behaviour. `update_required` raises the overlay; offline play never trips it (the network kill switch).
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add - **`ui/src/lib/retry.ts` `toGatewayError()`**: add
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`. `case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable; - **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) → shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys `window.open(import.meta.env.VITE_RUSTORE_URL, '_system')`; web → `location.reload()`. i18n keys
`update.title/body/action` (add siblings to the maintenance keys). `update.title/body/action` (add siblings to the maintenance keys).
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139). - **`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 }`. - **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'`); - **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via
Playwright `update.spec.ts` driving `__update.on()`. 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 **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. gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) ### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) — ✅ CODE-COMPLETE
Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player 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` 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 and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch
with no network**. with no network**.
1. **Bundle dictionaries in the APK.** **Status:** D.1D.6 are **done & verified** (foundations committed `bcd5a1d`; boot + reconciliation +
- New build step `ui/scripts/bundle-dicts.mjs`: copy `../scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg` Profile + the Android edge-to-edge safe-area fix committed on `feature/android-native`, 2026-07-12), with
→ `ui/dist/dict/<variant>@<DICT_VERSION>.dawg` (dictKey naming). Run only in the native pipeline `e2e/native.spec.ts` green (chromium + webkit) AND an **on-device emulator smoke** (Pixel_10 / API 37,
(after `pnpm build`, before `cap sync`). Web builds stay slim. airplane mode): cold-boot → offline guest lobby, offline vs_ai played end-to-end (bundled dawg — Hint
- Vite `define`: add `__DICT_VERSION__` from `VITE_DICT_VERSION` (mirrors `__APP_VERSION__`); placed "FEZ", the robot replied "NEEDFIRE"), New Game offers both modes. The smoke surfaced a native-chrome
declare it in `vite-env.d.ts`. bug on **Android 15+ edge-to-edge** (WebView < 140 reports `env(safe-area-inset-*)`=0 → the top nav draws
- **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: between the IndexedDB tier (line 73-85) under the status bar and the game's centre Hint button under the gesture-nav home indicator) — **fixed** by
and the network tier (line 87), add a **bundled tier** that `fetch('./dict/'+key+'.dawg')` consuming the SystemBars core plugin's injected `--safe-area-inset-*`: the bottom via the `--tg-safe-*` token
(relative — served from app assets on native, 404 on web → falls through). On a hit, build the (`app.css`), and the header's top inset via a native-only `.bar` rule (`Header.svelte` — its top inset was
`Dawg`, cache in memory, seed IndexedDB (`idbPutDawg`), and return. This is the only change that Telegram-fullscreen-scoped only, so the token alone did not reach it). No dep/config (the plugin ships in
lets a never-online device obtain a dictionary. `@capacitor/core` v8); full story in `.claude/CLAUDE.md`. Remaining for D: the reconcile-online leg
- Offline local-game creation must request the **bundled version**: where `NewGame.svelte` on-device (hits prod — a guest mint) and the deferred local-game-visibility decision (§G). **Verify every
offline-create and `localgame` resolve the dict version, use `__DICT_VERSION__` so the requested line ref below against current code.**
`(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 **Decisions locked this session (owner-approved) — bake these before implementing:**
returning a blob) and the local-guest boot decision (`shouldBootOffline` native-no-session); a - **The blocking Login is bypassed on native only.** Web / PWA / Telegram / VK keep the current
Playwright offline-first spec that boots with the network hook off and plays a local vs_ai move and online-session rule (they still need a prior session). The native channel always lands the user in the
starts a hotseat game. (Follow `docs/TESTING.md` layers; the mock e2e bypasses the codec, so keep the lobby, online or offline.
gate's server path in Go tests.) - **Soft registration reuses the existing `Profile` screen** as the guest sign-in / account surface (it
already shows the email / Telegram / VK upgrade options for a guest). No new sign-in UI is built in D.
- **Hide the Telegram + VK link buttons on the native build** on Profile: VK ID web-login is a full-page
redirect to `id.vk.com` that cannot return into the Capacitor app (it strands on the web redirect URI),
and the Telegram Login Widget is unreliable in a WebView. **Email works** (pure gateway calls, no
redirect). Native Telegram/VK login (native SDKs / deep-link OAuth — "the pretty native popups") is a
**separate later stage**, consistent with the locked "guest+email" surface and "Out of scope: VK/Telegram
login in the native build".
- **Local-guest display name** = localized `common.guest` ("Гость" / "Guest").
1. **Bundle dictionaries in the APK. ✅ DONE.**
- `ui/scripts/bundle-dicts.mjs`: copies the DAWGs from the unpacked **`scrabble-dictionary` release**
(dir via **`DICT_DIR`**; **NOT** `../scrabble-solver/dawg`) → `<OUT_DIR|dist>/dict/<variant>@<version>.dawg`.
`dictKey(variant,version)` = `` `${variant}@${version}` `` (`lib/dict/store.ts:31`). `dawgFor` maps
`scrabble_en→en_sowpods`, `scrabble_ru→ru_scrabble`, `erudit_ru→ru_erudit` (mirrors `e2e-dict.mjs`).
Version = `VITE_DICT_VERSION` (default `dev`); `OUT_DIR` overrides the root (the e2e points it at
`dist-e2e`). Run only in the native pipeline (after `pnpm build`, before `cap sync`); web builds skip
it and stay slim.
- Vite `define` `__DICT_VERSION__` (from `VITE_DICT_VERSION`, default `dev`; mirrors `__APP_VERSION__`)
+ declared in `vite-env.d.ts`.
- **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: a **bundled tier** sits between the IndexedDB
tier (now tier 1) and the network tier (now tier 3). **CORRECTION vs the original plan:** it is
**native-gated** (`clientChannel()` android/ios), NOT "fetch always, 404 on web". A relative
`fetch('./dict/'+key+'.dawg')` on the web would hit the **gateway's own session-gated `/dict/` route**
(a real server route), not a clean 404 — so the bundled fetch is skipped off-native and only tried in
a packaged app (its own assets). On a hit: build the `Dawg`, cache in memory, seed IndexedDB, return
(no network metric — a bundled hit is a local asset). The e2e simulates native (see Tests).
- Offline creates request the **bundled version**: `NewGame.svelte` offline vs_ai (`~line 84`) and
hotseat (`~line 277`) use `app.profile?.dictVersions?.[v] ?? __DICT_VERSION__`, so a profile-less
local guest gets the bundled `(variant, version)`.
2. **Local-guest identity. ✅ DONE.** `ui/src/lib/localguest.ts`: `localGuestId()` mints + persists a
device-local id in `localStorage` (`scrabble.localGuestId`, prefix `localguest:`, no crypto API for the
old engines) + `isLocalGuestId()`. The **display name is not in the module** — it is `t('common.guest')`
at the call site, so the module stays i18n-free and node-testable. `NewGame.svelte` vs_ai human seat
(`~line 99`) now uses `accountId: app.session?.userId ?? localGuestId()` and
`name: app.profile?.displayName ?? t('common.guest')`. Hotseat seats stay independent local identities
(`buildSeats` — unchanged).
3. **Cold offline-first boot — ✅ DONE & verified (2026-07-12)** (`ui/src/lib/app.svelte.ts`). **High
blast-radius: app startup for every platform — every change is native-gated; web / PWA / TG / VK stay
byte-for-byte.** As built:
- The blocking Login was **`bootstrap()`'s `else` of `if (saved)`** (no cached session, formerly
`navigate('/login')`). On native it is now the offline-first entry: `localGuestId()` +
`setOfflineMode(true, false)` (**non-sticky** auto-offline, so reconciliation may clear it) + land in
the lobby (`navigate('/')` only if on login/confirm) + `scheduleRecovery(0)` to kick reconciliation.
Web keeps the `navigate('/login')` else. `app.ready = true`.
- **CORRECTION — `shouldBootOffline` was NOT changed.** The native-no-session case is fully caught in
that `else` branch (there `hasSession` is provably false and `saved`'s `if`-block — the web
canOffline logic — is skipped), so a `native` param would be unused in the load-bearing path. The
plan's "add a native-no-session path to `shouldBootOffline`" is dropped, and the matching unit test
with it (the boot decision is exercised by `e2e/native.spec.ts`).
- **`offlinePreloadEligible` was NOT changed either** — it already returns false off a standalone web
PWA (native is not `isStandalone()`), so the server preload is already skipped on native; no edit needed.
- The lobby renders without a session/profile: `Lobby.svelte`/`NewGame.svelte` use optional chaining
and the offline branch loads only device-local games (no session-gated `gamesList`). `NewGame` gates
its offline flows on `guest || offlineMode.active`, both true for the local guest.
4. **Lazy server-guest reconciliation + the silent seam — ✅ DONE & verified (2026-07-12).** As built:
- **Silent seam:** `exec` gained `opts?: { silent?: boolean; allowOffline?: boolean }`.
**CORRECTION — `silent` alone was not enough:** reconciliation runs while the app is still in
auto-offline, so `exec`'s `assertOnline()` kill switch would refuse it — hence **`allowOffline`**
(bypass the kill switch, like the reachability probe). `silent` gates the two `exec`
`reportUpdateRequired()` sites (result-code + catch); the `subscribe` catch keeps the overlay
(reconciliation never subscribes). New `authGuestSilent(locale)` on the `GatewayClient` interface
(`lib/client.ts`), the real transport (`{ silent: true, allowOffline: true }`) and the mock.
- **Reconciliation — `reconcileServerGuest()` (`app.svelte.ts`):** native + no session → `authGuestSilent`,
`setOfflineMode(false)` **before** `adoptSession` (so adopt's `profileGet`/stream ride the online
transport, not the kill switch), then adopt. Guarded by `reconcileInFlight` + `app.session` (never a
second guest). `update_required`/unreachable are swallowed (stay a local guest). Local games stay
device-only. Fired at boot and by the recovery poll + the online event.
- **CORRECTION — `checkReachable` cannot gate a session-less guest:** its authenticated probe rejects
with no token, so it never reports "reachable" for a local guest. The recovery poll (`scheduleRecovery`)
therefore routes the **no-session** case straight through `reconcileServerGuest` (the mint attempt IS
the reachability check); the session case keeps `checkReachable`.
- **`scheduleRecovery` is now mock-guarded** (like `initNetworkReactivity`) so the e2e's boot does not
race a mock reconcile to online before the spec observes the offline lobby; the e2e drives
reconciliation via a new **`window.__native.reconcile()`** hook (mirrors `__conn`/`__maint`/`__update`).
5. **Both offline modes render for the local guest — ✅ DONE & verified (2026-07-12).** `NewGame` gates the
offline flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both
"quick" (vs_ai) and "with friends" (hotseat) show. The vs_ai human seat already uses the local-guest id
(D.2). Verified: `e2e/native.spec.ts` plays a vs_ai move + starts a hotseat game, and the on-device smoke
(Pixel_10 / API 37) played a full offline vs_ai turn (Hint "FEZ", robot "NEEDFIRE") and reached New Game
offering both modes.
6. **Soft registration = reuse the `Profile` screen — ✅ DONE & verified (2026-07-12).** As built:
- **Reaching Profile needed no change:** guests keep the Profile tab (`SettingsHub.svelte:27` only hides
friends/wallet for guests; `:33` hides profile/friends/wallet in **offline** mode), so once the native
guest is online and reconciled (D.4 clears the auto-offline) Profile shows and email sign-in works.
Offline you cannot register anyway (every method needs the server), so the offline-hidden behaviour is
correct.
- **Hid tg/vk on native:** `Profile.svelte` gates `telegramLinkable = loginWidgetAvailable() && !nativeShell`
and `vkLinkable = vkWebLinkAvailable() && !nativeShell` (`nativeShell = clientChannel()` android/ios), so
both LINK buttons are hidden on native while email + account management (and an existing link's UNLINK — a
redirect-free `link.unlink` call) stay. Verified in `e2e/native.spec.ts` (the reconcile test opens Profile
and asserts no Link Telegram / Link VK, email present; `loginWidgetAvailable`/`vkWebLinkAvailable` are true
under the mock, so the buttons show on web — the native count-0 proves the gate).
**Tests — ✅ DONE for D.3/D.4 (2026-07-12):**
- **Unit (vitest, node):** no NEW unit test was added for D.3/D.4 — the boot decision, reconciliation and
silent seam live in `$state`/transport modules the plugin-less vitest cannot import (cf. `update.svelte.ts`),
and the `shouldBootOffline` native path was dropped (§D.3). `localguest` + the bundled-dict tier are D.1/D.2
coverage (a bundled-tier `fetch`-mock unit test remains a D-close nicety if wanted). `vitest` stays 591.
- **Playwright — new `e2e/native.spec.ts`** (NOT an extension of `offline.spec`): **GOTCHA — inject
`window.androidBridge`, NOT `window.Capacitor.getPlatform`.** `@capacitor/core` (loaded during boot by
`initNativeShell`) derives the platform from `window.androidBridge` / `window.webkit` and **replaces** any
pre-set `window.Capacitor` with its own shim whose `getPlatform()` is `web` — so a bare injected
`window.Capacitor = { getPlatform: () => 'android' }` is clobbered and the boot falls to `/login`.
`simulateNative` injects `window.androidBridge = { postMessage(){} }` (+ a Capacitor stub for the pre-core
window), which makes `clientChannel()` resolve to `android`. Two tests: (1) boot → **assert the offline guest
lobby (not login)**, play a local vs_ai move (bundled dawg), Back, `window.__native.reconcile()` → assert
**online lights up** (offline tint gone, the seeded online game appears, Stats re-enabled); (2) boot → start a
2-player hotseat game. The `playwright.config.ts` webServer now also runs `DICT_DIR="${E2E_DICT_DIR:-…}"
OUT_DIR=dist-e2e node scripts/bundle-dicts.mjs` → `dist-e2e/dict/<variant>@<version>.dawg` (version flows from
`VITE_DICT_VERSION`, default `dev`, matching `__DICT_VERSION__`). **Ran locally green (chromium + webkit, 4/4)
against the installed browsers + the sibling `scrabble-solver/dawg`; also runs in CI.**
- **GOTCHA resolved:** `initNativeShell` was made **bridge-tolerant** (`try/catch` around the `@capacitor/app`
import + `addListener`) rather than stubbing a fake Capacitor bridge — cleaner and defensive for a real WebView
that lacks the App plugin.
**Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and **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 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. silently establishes a server guest (Profile + online play light up). Emulator smoke recipe: `.claude/CLAUDE.md`
→ "Native Android build", with the extra `DICT_DIR=<release> VITE_DICT_VERSION=<ver> node
scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`.
### E. CI — signed APK artifact ### E. CI — signed APK artifact — ✅ CODE-COMPLETE (workflow written + `build.gradle` wired + locally proven; the signed dispatch is §G)
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps: `workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
1. Checkout this repo **and the sibling `scrabble-solver`** — copy the checkout + Node/pnpm setup 1. Checkout this repo; copy the Node/pnpm setup block from `.gitea/workflows/ci.yaml` (the `ui` job).
block from `.gitea/workflows/ci.yaml` (the `ui` job). The sibling is needed for the bundled dicts. 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`. 2. `pnpm install --frozen-lockfile`.
3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`, 3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`,
`VITE_DICT_VERSION` = the release dict version. `VITE_DICT_VERSION` = the release dict version.
4. `node ui/scripts/bundle-dicts.mjs` (copy the `.dawg` into `ui/dist/dict/`). 4. `DICT_DIR=${GITHUB_WORKSPACE}/dawg node ui/scripts/bundle-dicts.mjs` (copy the release `.dawg`
5. Setup JDK 17 (`actions/setup-java@v4`, temurin 17). files into `ui/dist/dict/`).
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-34`, 5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21).
`build-tools;34.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual. 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.
*As built:* the runner is a **host-executor** on a remote Debian host, so the **SDK is host-provisioned**
(`ANDROID_SDK_DIR` var, default `/opt/android-sdk`) and a **Verify the host Android SDK** step fail-fasts on a
missing package / no runner-user access; JDK 21 still comes from `actions/setup-java`.
7. `npx cap sync android`. 7. `npx cap sync android`.
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the 8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
tag (scheme below). tag (scheme below).
@@ -417,11 +664,14 @@ for the MVP.
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` → **`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}"`. `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`: Wire in `ui/android/app/build.gradle` `defaultConfig`:
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`, `versionCode = (project.findProperty('versionCode') ?: '1').toInteger()`,
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release` `versionName = (project.findProperty('versionName') ?: '0.0.0').toString()`, plus a guarded
reading env/props (guarded so a local `assembleDebug` still builds unsigned). `signingConfigs.release` reading env/props. **Use `=` assignment, not the command form**
`versionCode (…).toInteger()` — the latter binds `.toInteger()` to the DSL setter's null return
(`> 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** + - `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 / the gate×offline rule. New sections — the **identity model** (local guest / server guest /
@@ -434,14 +684,23 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned).
test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to 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; 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). 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`, - Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_RUSTORE_URL`,
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. `VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. Gitea CI vars/secrets
for the APK build: `ANDROID_RUSTORE_URL` (var, empty until publish), `PROD_PUBLIC_BASE_URL` (reused as the
gateway origin), and the release-signing secrets `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` /
`ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`.
- `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore - `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 upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
ships an incompatible wire change. ships an incompatible wire change.
### G. Release + owner handoff ### G. Release + owner handoff
0. **Land the offline-model redesign — ✅ DONE (O1O7, 2026-07-13; staged detail below).** Resolved the deferred
native local-game-visibility decision — a native guest now sees / resumes their device-local games once online
(the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline
is an implicit **net-state machine** (O1/O2), the lobby is unified (O5), and the **two-tier** version gate is in
(O4). Cross-cutting (web + native + a small additive backend/wire bit); **contour-safe** (both version vars empty
⇒ dormant; the wire add is additive). The remaining G steps below are the release chain.
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant). 1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
2. Promote `development` → `master`; tag `vX.Y.0`. 2. Promote `development` → `master`; tag `vX.Y.0`.
3. Dispatch `android-build`; watch green; retrieve the signed APK. 3. Dispatch `android-build`; watch green; retrieve the signed APK.
@@ -450,6 +709,324 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned).
--- ---
## Offline-model redesign (design — resolves G-step-0)
> Owner-approved design (brainstormed 2026-07-12). Cross-cutting web + native + a small **additive**
> backend/wire bit. This is the Android release's G-step-0 and its own staged work; `writing-plans` turns it
> into the implementation plan. English throughout; the FBS change is additive-only (§frozen contract).
### Goal & locked decisions
Remove the explicit online/offline toggle. Offline becomes an **implicit, detected** state driven by a single
**net-state machine**. vs_ai stays as-is (offline → local, online → server; the app decides by the detected
state). The lobby becomes **unified**. The version gate gains a **soft** tier and its hard tier degrades to
offline instead of a terminal lockout. TG/VK stay **always-online**. Client-only except the additive
soft-tier threshold + signal.
| Decision | Choice |
|---|---|
| Offline trigger | Implicit — detected connectivity + version, **no user toggle** |
| State model | **Single net-state machine** (replaces the two-layer `connection` / `offlineMode` split) |
| Switch UX | **Auto + a toast**, no dialog; hysteresis against flap; self-heal to online |
| Server vs_ai | **Kept** — offline → local, online → server; identical create flow, app decides by state |
| With-friends create | Explicit online/offline choice; offline **disabled (greyed)** when no network; create always works (→ local hotseat) |
| Lobby offline | Unified; server games **greyed from the last cache**, local games active |
| Version — critical (hard) | Terminal lockout → now a **notice "Update / Play offline"**, then forced offline |
| Version — recommended (soft) | **Built now** — notify but still play online (`GATEWAY_RECOMMENDED_CLIENT_VERSION` + an additive signal) |
| TG/VK | **Always online** (exempt); no local lobby / offline states |
| Native net signal | Add **`@capacitor/network`** as a hint; web keeps `navigator` + gateway probes |
| Migration | Clear the persisted `offlinePref` (nobody stuck without a toggle) |
### The net-state machine
One reactive `netState` absorbs `connection.svelte.ts` + `offline.svelte.ts`; the ~14 readers of
`offlineMode.active` migrate to a derived `offline` boolean and the "Connecting…" readers to `connecting`.
Written as a pure reducer (reusable for a future iOS shell).
**States**
- `online` — gateway reachable, version accepted. Full features. May carry an orthogonal dismissible
**`updateRecommended`** nudge (soft tier); play continues.
- `connecting` — a call/probe is currently failing but the hysteresis window has not elapsed. "Connecting…";
chrome stays online. Transient — the anti-flap buffer. **Not** offline (no kill-switch).
- `offlineNoNetwork` — sustained unreachable (hysteresis exceeded). Offline mode (blue chrome, local-only
active lobby, transport kill-switch). Self-heals.
- `offlineVersionLocked` — gateway reachable but version < min (critical). Offline mode + the
"Update / Play offline" notice. **Sticky** — exits only via an app update (fresh version next boot).
`offline` (kill-switch / blue chrome / local play) = `offlineNoNetwork || offlineVersionLocked`.
**Events:** `callFailed` · `callOk`/`probeOk` · `probeFailed` · `osOffline`/`osOnline` (navigator /
`@capacitor/network` hints) · `versionRejected` (update_required) · `versionRecommended` (soft) · `boot`.
**Transitions**
| From | Event | To | Side-effect |
|---|---|---|---|
| online | callFailed / osOffline | connecting | start hysteresis (probe + timer) |
| connecting | probeOk / callOk | online | — |
| connecting | hysteresis exceeded (K fails / `OFFLINE_DEBOUNCE_MS`) | offlineNoNetwork | toast "offline" |
| offlineNoNetwork | osOnline | (probe) | trigger a probe; stay until it wins |
| offlineNoNetwork | probeOk / callOk | online | toast "back online" |
| **any** | versionRejected | offlineVersionLocked | show the Update/Offline notice (supersedes the soft nudge) |
| offlineVersionLocked | probe (still rejected) | offlineVersionLocked | stays; only an app update exits |
| online | versionRecommended | online (+ nudge) | dismissible "update available" banner |
| boot (no net) | — | connecting → offlineNoNetwork | offline-first |
| boot (version too old) | versionRejected | offlineVersionLocked | notice |
**Hysteresis / anti-flap (explicit):** `online → offline` only after the probe fails **and** (K consecutive
failures **or** `connecting` held ≥ `OFFLINE_DEBOUNCE_MS`); a single blip lives entirely in `connecting`
(spinner only). Recovery to `online` is immediate on the first `probeOk`. `osOnline` never flips to online by
itself — it only **triggers** a probe (navigator / `@capacitor/network` can lie; the probe decides). Reuses
the existing probe watcher + backoff.
**Session-less guest (from D):** the probe is authenticated, so a not-yet-reconciled local guest cannot probe;
there the **reconcile attempt (`auth.guest`) IS the connectivity signal** — success → online + adopt;
failure / `update_required` → stays offline (swallowed, as D already does).
### Edge cases (each gets a test)
1. Rapid flap (fail→ok inside the window) → never leaves `connecting`; no chrome thrash.
2. Cold boot, no network → `offlineNoNetwork`, offline-first lobby.
3. Cold boot, gateway up but version < min → `offlineVersionLocked` + notice.
4. Cold boot, session-less guest → reconcile = probe; ok→online, fail→offline.
5. Mid-session min-version bump → next call `versionRejected` → `offlineVersionLocked` mid-play.
6. Captive portal: `osOnline` fires but the gateway is down → probe fails → stays offline.
7. Recovery race: `probeOk` + a queued `callOk` together → one idempotent transition to online.
8. Soft nudge, then a hard reject → escalate to `offlineVersionLocked` (notice supersedes the banner).
9. `offlineVersionLocked` on plain web with no cached dicts → offline but create disabled → the notice is the only action.
10. Offline → create local vs_ai → back online → the local game persists and shows in the unified lobby.
11. Persisted stale `offlinePref` on upgrade → ignored/cleared; never stuck offline.
12. TG/VK → the machine is inert (always `online`); no offline states, no local lobby.
### Version gate — two tiers (backend + wire, additive)
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`** (exists): `version < min` → `update_required` (Execute
result_code / Subscribe `FailedPrecondition`). Client reaction **changes**: no terminal overlay →
`offlineVersionLocked` + a **notice** with "Update" (native → `VITE_RUSTORE_URL`, web → reload) and
"Play offline" (dismiss → stay offline). Background reconcile stays silent (D).
- **Soft (recommended) — NEW `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` → a
**non-blocking** signal. Wire: **additive** — a **response header** `X-Update-Recommended: 1` on gated
responses (headers are the version-tolerant layer, like `X-Client-Version`); the call still succeeds and
never carries a breaking payload change. Client shows a **dismissible** "update available" banner and plays
online. Empty ⇒ soft tier off.
- Config: `recommended` must be ≥ `min` (validated at load); both empty ⇒ the gate is fully dormant (web
unchanged). Frozen-contract compliance: the soft signal is additive/trailing; the `update_required`
sentinel is unchanged.
### Unified lobby
Merge `localSource.list()` + `gateway.gamesList()`. Offline: local games active + **server games greyed from
the last `lobbycache` snapshot** (un-openable, an "offline" hint). Identity: recognise **both** the
`server user id` and the `localGuestId` as "you" (they differ after reconciliation) for turn / medal logic.
One `lobbysort`; ids never collide. Removes the `loadSeq` mode-exclusive branch — both sources feed one list.
TG/VK: server-only (unchanged).
### Create flows
- **vs_ai (quick):** unchanged logic — offline → `localSource.create`, online → `lobbyEnqueue` — driven by
`netState`. **Dict guard:** if the variant's dawg is unavailable offline, disable create with a reason.
- **With-friends:** a new **online/offline segmented control** (online = friend invite, offline = hotseat;
both forms exist). No network → the online segment is disabled ("needs network"), offline preselected.
### Settings / chrome / migration / platform
Remove the offline-toggle section from `Settings.svelte` (+ `requestOffline`, the `offlineEligible` gate);
`SettingsHub` keeps disabling Profile/Friends/Wallet while offline (now from `netState`). On upgrade,
ignore/clear the persisted `offlinePref` (never strand a deliberate-offline user). Implicit offline applies to
**native (always) + web (plain tab + PWA)**; **TG/VK exempt**. Plain-web offline is best-effort (local play
only for variants whose dawg is cached). No server / data migration (server vs_ai + all server games
untouched).
### Test matrix (exhaustive — owner requirement)
- **vitest (pure):** the net-state machine as a pure reducer — **every** transition, the hysteresis/debounce
(blip vs sustained), the session-less-guest reconcile-as-probe branch, the two-tier version decision
(min / recommended ordering + all three bands), the dict guard, the identity "self" test (both ids). Every
edge case #1#12 gets a case.
- **Go:** `clientver`/config extended for `GATEWAY_RECOMMENDED_CLIENT_VERSION` (parse + ordering validation);
`connectsrv` for the soft signal (`v < min` ⇒ hard, `min ≤ v < recommended` ⇒ soft, `v ≥ recommended` ⇒
none, absent/garbled ⇒ fail-open).
- **Playwright (mock):** auto online→offline (toast) + self-heal; `offlineVersionLocked` notice → "Play
offline" → local play; unified lobby (greyed server games offline; both active online); with-friends toggle
(online disabled offline); the soft nudge banner; update `native.spec.ts`.
- **Docs (revise the F bake):** ARCHITECTURE §2 gate → two tiers + graceful degrade; §3 offline model;
FUNCTIONAL (+`_ru`) offline + "update required"; TESTING; deploy/README the two version vars.
### Scope / sequencing
Client refactor + a small **additive** backend/wire bit; **contour-safe** (both version vars empty ⇒ dormant;
the wire add is additive). It is **G-step-0** and gates the Android release. After it lands: the normal G
chain (PR → development → contour verify → master → tag → dispatch `android-build`).
### Out of scope
Dropping server vs_ai (kept). TG/VK offline. In-app store-update SDK. The iOS shell (the machine is written to
be reusable for it; iOS is not built here).
### Implementation stages
Executed via `stage-implementation` (interview on every fork, tests at each layer, bake docs). Ordered so each
stage leaves the app working and independently testable. **TDD:** the pure machine (O1) is written test-first.
Standing constraints (from the design): one `netState` machine; hysteresis explicit; **server vs_ai
untouched**; wire changes **additive-only**; **TG/VK inert** (always online); **contour-safe** (both version
vars empty ⇒ dormant). Minimal-diff: keep `connection`/`offline` module paths as thin **derived shims** over
`netState` so the ~14 `offlineMode.active` consumers are not mass-renamed (single source of truth underneath).
- **O1 — Pure net-state reducer + exhaustive unit tests (test-first). — ✅ DONE (2026-07-12).**
- Files — Create: `ui/src/lib/netstate.ts`, `ui/src/lib/netstate.test.ts`.
- Interfaces (as built) — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`;
`type NetEvent` (`boot`/`callOk`/`callFailed`/`probeOk`/`probeFailed`/`osOnline`/`osOffline`/`versionRejected`/
`versionRecommended`); `reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig) => NetResult` where
`NetInput = { type: NetEvent; at: number }` (**time-on-the-event** — owner-chosen option A: the reducer stays
a pure function of `(state, event)` and decides *both* hysteresis branches; O2 stamps `Date.now()`),
`NetSnapshot = { state; fails; connectingSince }` (the hysteresis bookkeeping is carried in the reduced value
so `reduce` is pure yet the whole anti-flap is unit-tested), `NetResult = { next: NetSnapshot; effects: Effect[] }`,
`Effect = {kind:'toast',toast:'offline'|'online'} | {kind:'startProbe'} | {kind:'showVersionNotice'} | {kind:'setNudge'}`,
`NetConfig = { k; debounceMs }` (K + `OFFLINE_DEBOUNCE_MS`), plus `INITIAL` (optimistic pre-boot `online`).
Consumes: nothing (pure). **As-built semantics:** entry via `callFailed`/`probeFailed` counts as `fails 1`;
`osOffline` is a hint (enters `connecting` with `fails 0`, only `startProbe`) — so a single blip stays `connecting`
for K≥2; `boot` resets from any state (the sole exit from the sticky lock) → `connecting` + `startProbe`;
`offlineVersionLocked` is sticky (no connectivity event exits it, notice shown once on entry); the soft nudge is
**effect-only** (`setNudge` from `online`), latched later in O4, not stored in the snapshot.
- Acceptance: every transition-table row + all 12 edge cases pass as pure `reduce` assertions; the blip vs
sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected`
from **any** state → `offlineVersionLocked`; `versionRecommended` sets the nudge only from `online`.
- Targeted tests: `netstate.test.ts` (vitest, node — pure, no jsdom). **31 assertions, green** (transition table,
hysteresis blip/K/debounce, two-tier version gate, edge cases #1#12, purity).
- **O2 — Runtime store + wire the events; migrate the two modules. — ✅ DONE (2026-07-12).**
- Files (as built) — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`; the derived
`state`/`online`/`connecting`/`offline`/`versionLocked` getters; one self-rescheduling probe/recovery watcher —
backoff while `connecting`, a 4 s poll while `offlineNoNetwork`, idle otherwise; the OS-signal wiring). Modify:
`ui/src/lib/connection.svelte.ts` + `ui/src/lib/offline.svelte.ts` (thin shims over `netState` — `connection.online`
= the online state, `offlineMode.active` = the machine `offline`; `reportOffline`→`callFailed` **once** from online,
`reportOnline`→`callOk`), `ui/src/lib/update.svelte.ts` (`reportUpdateRequired` also emits `versionRejected`),
`ui/src/lib/native.ts` (+`initNativeNetwork` — `@capacitor/network` → `osOnline`/`osOffline`),
`ui/src/lib/app.svelte.ts` (the boot/recovery-seam rewrite), `ui/src/lib/gateway.ts` (the mock `__net` hook),
`ui/src/App.svelte` (dialog removal), `ui/src/lib/i18n/{en,ru}.ts` (`net.offline`/`net.online` toast), `ui/package.json`
(+`@capacitor/network@8.0.1`). **Owner-confirmed scope A (full migration):** the store subsumes the old
`scheduleRecovery` poll + `initNetworkReactivity` listeners + the cold-boot `promptOfflineChoice` **dialog** (removed —
"auto + toast, no dialog"); the native offline-first boot + the web unreachable-cached boot now enter via `bootOffline()`.
- Interfaces (as built) — Produces: `netState` (`.state`/`.online`/`.connecting`/`.offline`/`.versionLocked`),
`emit(type)` (stamps `Date.now()`), `bootOffline(kickNow?)`, `checkReachable`, `resetNetState`, `initNetSignals`, and a
**register-hook inversion** so the store stays a leaf module (no cycle with `app.svelte.ts`): `registerProbe`
(transport's reachability read), `registerRecovery` (the app's smart reconcile-or-reachability), `registerNetToast`
(i18n toast). `NetConfig = { k: 3, debounceMs: 15000 }` (owner-confirmed). **Wiring:** `reportOffline` enters
`connecting` once (only from online) and the **probe decides** offline via K/debounce; the session-less native
reconcile IS the probe (`recover()` → `reconcileServerGuest` → `emit('callOk')` before adopt). **`boot` is not
emitted** — a real relaunch reloads the bundle to `INITIAL` online, which also clears the sticky version lock; O1's
`boot` handling stays valid but unused here. **TG/VK inertness falls out for free:** the registrations run only past
the Telegram/VK early-returns in `bootstrap`, so those channels are never fed an offline signal (they can show
`connecting` but never reach `offline`).
- Acceptance: airplane-mode → `offlineNoNetwork` → back → `online` via the machine; native uses `@capacitor/network`,
web `navigator`; the derived `offline` matches the old `offlineMode.active` for every consumer (no regression). **Verified.**
- Targeted tests: `e2e/offline.spec.ts` **rewritten** (implicit offline via the mock `__net` hook — no toggle — + the
toast + local play + persist + self-heal), `e2e/hotseat.spec.ts` migrated off the toggle, `e2e/native.spec.ts` green
(the native boot/offline/reconcile IS the store's e2e). **Full suite 119/119 on chromium + webkit; svelte-check 0/0;
vitest 622; `cap sync` registers `@capacitor/network`.**
- **O3 — Remove the explicit offline toggle + migrate the pref. — ✅ DONE (2026-07-12).**
- Files (as built) — Modify: `ui/src/screens/Settings.svelte` (deleted the whole "Play mode" Online/Offline segment +
`offlineEligible` + `goOffline` + the `checking`/`needsData` state + the now-unused `isStandalone`/`insideVK`/offline
imports + the `.onote` CSS), `ui/src/lib/offline.svelte.ts` (dropped the inert `setOfflineMode`/`requestOffline`/
`TOGGLE_READY_BUDGET_MS` stubs + the dead `offlineMode.auto` getter — `offlineMode.active` = `netState.offline` stays),
`ui/src/lib/offline.ts` (dropped `loadOfflinePref`/`saveOfflinePref`/`shouldBootOffline`/`offlineReady`/`missingDicts`/
`raceOfflineReady`; **new** `clearOfflinePref`; only `offlinePreloadEligible` + the cleanup remain), `ui/src/lib/app.svelte.ts`
(calls `clearOfflinePref()` once at boot, before the Mini-App branches), `ui/src/lib/i18n/{en,ru}.ts` (pruned 7 orphaned
keys each: `settings.{offlineMode,online,offlineChecking,offlineNeedsData}` + `offline.prompt{Title,Yes,No}`). **Deleted**
`ui/src/lib/dict/offlineready.ts` (orphaned once `requestOffline` went). Tests: `ui/src/lib/offline.test.ts` slimmed to
`offlinePreloadEligible` + `clearOfflinePref`; `ui/e2e/offline.spec.ts` +1 case.
- **Kept, not changed:** `settings.offline` (the Header offline chip still uses it) + `offline.preloadWarning` (the lobby
dict-preload notice). **`SettingsHub.svelte` needed no change** — its tab-disable already reads `offlineMode.active`,
which is `netState.offline` (the O2 shim); the plan's "tab-disable from `netState`" is satisfied without a mass-rename.
- Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck) and is cleared; offline
chrome/tab-gating still driven from `netState`. **Verified.**
- Targeted tests: `e2e/offline.spec.ts` new case (a stale `scrabble.offlineMode` → boots online, key cleared, Settings has
no `Offline` toggle even in the eligible context); `offline.test.ts` (`clearOfflinePref`). **Full suite 120/120 on
chromium + webkit; svelte-check 0/0; vitest 616.**
- **O4 — Two-tier version gate. — ✅ DONE (2026-07-13).**
- Files (as built) — Backend: `gateway/internal/config/config.go` (+`RecommendedClientVersion` from
`GATEWAY_RECOMMENDED_CLIENT_VERSION`; validate parseable + `≥ MinClientVersion`, an empty min imposing no lower bound),
`gateway/internal/connectsrv/server.go` (soft-tier fields `recClient`/`recOn`; `clientUpdateRecommended` = `min ≤ v <
recommended`, fail-open; Execute sets `X-Update-Recommended: 1` on any served response in the band via a named-return
`defer`; hard band + Subscribe unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (a
Connect-ES **interceptor** reads `x-update-recommended` on unary responses ⇒ `reportUpdateRecommended`),
`ui/src/lib/update.svelte.ts` (dropped the terminal `updateRequired` latch — `reportUpdateRequired` now only emits
`versionRejected`; new `updateRecommended` store + `latchUpdateNudge`/`dismissUpdateNudge`/`reportUpdateRecommended` +
shared `openUpdate`), `ui/src/lib/netstate.svelte.ts` (`registerNetNudge`; the `setNudge` effect fires it),
`ui/src/lib/app.svelte.ts` (`registerNetNudge(latchUpdateNudge)`), `ui/src/lib/gateway.ts` (mock `__update.recommend`),
`ui/src/lib/i18n/{en,ru}.ts` (`update.playOffline` + `update.available`). Rework: `ui/src/components/UpdateOverlay.svelte`
(terminal → dismissable notice reading `netState.versionLocked`, "Update" + "Play offline"). New:
`ui/src/components/UpdateNudge.svelte` (the soft banner, rendered **bottom of the lobby, above the tab bar** — shown only
while `online && updateRecommended.active && !dismissed`).
- Owner-chosen (interview): the hard notice keeps the **full-screen** form (now dismissable); the soft nudge is a
**bottom-of-lobby** banner (not in-game); the header rides **Execute only** (Subscribe untouched); read via a transport
interceptor; nudge dismiss is **per-session**; the nudge latches via `setNudge → registerNetNudge` (machine is the source,
"only from online"). `update.body` copy kept as-is (owner).
- Acceptance: `v<min` → notice → "Play offline" → offline lobby; `min≤v<recommended` → dismissible banner, online continues;
`v≥recommended`/both empty → nothing; fail-open on an absent/garbled header. **Verified.**
- Targeted tests: Go `config_test` (`TestLoadRecommendedClientVersion`) + `server_test` (`TestExecuteUpdateRecommended` — 3
bands + at-min/at-recommended boundaries + fail-open + recommended-alone + dormant); `e2e/update.spec.ts` rewritten (notice
+ both actions; Update reloads; Play offline → offline lobby; the soft nudge shows in the lobby + dismisses). **Full gateway
go-test/build/vet/gofmt clean; e2e 122/122 chromium + webkit; svelte-check 0/0; vitest 616.**
- **O5 — Unified lobby. — ✅ DONE (2026-07-13).**
- Files (as built) — `ui/src/screens/Lobby.svelte` (`load()` merges `localSource.list()` + `gateway.gamesList()`;
online ⇒ `[...local, ...server]`; offline ⇒ `[...localFresh, ...cachedServer]` greyed; the mode-exclusive branch is
gone; `grey(g) = offline && !isLocalGameId` drives a `.rowwrap.greyed` dim + an un-openable `openGame` that toasts
`net.offline`; **invitations hidden offline** — owner addition — via `{#if invitations.length && !offline}`), `ui/src/lib/lobbysort.ts`
+ `ui/src/lib/result.ts` (`myId: string` → `myIds: readonly string[]` self-set across `isMyTurn`/`scoreStanding`/
`gamePhase`/`groupGames`/`resultBadge`; game seat-matching uses `myIds.includes(...)`), `ui/src/lib/lobbycache.ts`
(dropped the `offline` snapshot key — **one unified snapshot**; `getLobby()` no arg; offline reuses its server games),
`ui/src/screens/NewGame.svelte` (`getLobby()` + filter to server games — device-local games do not count toward the
server per-kind limit), `ui/src/lib/localguest`/`telegram`/`vk` imports in the lobby.
- **Identity split:** game functions take the self-set `myIds = [session.userId, localGuestId()]` (a reconciled
guest's local games seat you under `localGuestId`, server games under `userId`, ids never collide); **invitations
keep `session.userId`** (a server concept). **TG/VK server-only:** the local merge is gated on
`insideTelegram()`/`insideVK()` (the shared-origin local store must not leak into a mini-app).
- Acceptance: online ⇒ local + server both listed; offline ⇒ local active + server greyed-from-cache (un-openable);
a reconciled guest's local games stay visible online (**closes G-step-0**); invitations hidden offline; TG/VK
server-only. **Verified.**
- Targeted tests: `lobbysort.test.ts` + `result.test.ts` extended (two-id "self" recognition), `lobbycache.test.ts`
reworked (unified snapshot, mode-aware tests removed); `e2e/native.spec.ts` proves the local vs_ai game stays listed
online (G-step-0); `e2e/offline.spec.ts` + `hotseat.spec.ts` + `update.spec.ts` updated for greyed-from-cache server
games offline. **Full suite 122/122 on chromium + webkit; svelte-check 0/0; vitest 617.**
- **O6 — Create flows. — ✅ DONE (2026-07-13).**
- Files (as built) — `ui/src/screens/NewGame.svelte`: **with-friends** now carries a `friendsMode` segmented control
(`new.playRemote` = online invite / `new.playLocal` = offline hotseat); the online segment is `disabled` when
`offlineMode.active` and an `$effect` forces `friendsMode='offline'` there (a `new.needsNetwork` note explains it);
the friends markup is restructured from the mode-exclusive `{:else if offlineMode.active}` chain into
`{:else} <segment> {#if friendsMode==='offline'} hotseat {:else if !friends} noFriends {:else} invite`. **vs_ai
unchanged** (verified: `find()` still offline→`localSource.create`, online→`lobbyEnqueue`). **Dict guard** (owner-chosen
**async getDawg**): a `dawgReady`/`dictBlocked` pair — an `$effect` awaits `getDawg(guardVariant, version)` while
offline (`guardVariant` = the quick pick or the hotseat pick) and disables create + shows a `new.dictUnavailable` note
only on a **confirmed** miss (`dawgReady === false`, never while `null`/checking, so a still-warming dawg does not
false-block). `ui/src/lib/i18n/{en,ru}.ts` (`new.{playRemote,playLocal,needsNetwork,dictUnavailable}`).
- Owner-chosen (interview): the dict guard uses the **async getDawg** precheck (no reliable sync signal; native bundles
every variant so it mostly guards web offline); **guest gating unchanged** — an online guest still sees vs_ai only (no
new online-guest hotseat); a guest offline keeps its hotseat.
- Acceptance: with-friends online = invite / offline = hotseat; no network ⇒ online disabled, offline preselected,
create works; vs_ai offline→local / online→server unchanged; create disabled with a reason on an unavailable offline
dawg. **Verified.**
- Targeted tests: `e2e/hotseat.spec.ts` asserts the offline segment (online disabled, hotseat forced); `offline.spec.ts`
+ `native.spec.ts` exercise vs_ai-local + hotseat create through the new segment. **Full suite 122/122 on chromium +
webkit; svelte-check 0/0; vitest 617.**
- **O7 — Docs + native e2e. — ✅ DONE (2026-07-13).**
- Files (as built) — `docs/ARCHITECTURE.md`: **§2** rewrote the connectivity signal into the **net-state machine**
(four states, implicit offline, hysteresis, self-heal, TG/VK-exempt), the version gate into the **two tiers** (hard →
`offlineVersionLocked` + dismissable notice, *not* terminal; soft → `X-Update-Recommended` nudge), and the gate×offline
rule; **§13** (PWA/offline) rewrote the offline model from the deliberate toggle/dialog to implicit detection + the
**unified lobby** (greyed cached server games) + the with-friends **segment** + the dict guard. `docs/FUNCTIONAL.md`
(+`_ru` mirror): rewrote *Offline mode* (implicit, unified lobby, segment) and added a *Staying up to date* /
*«Актуальная версия»* story (Update/Play-offline notice + the soft nudge). `docs/TESTING.md`: the `netstate.test.ts`
reducer layer, the `__net`-driven offline spec, the two-tier `update.spec`, the soft-tier Go server/config tests, and
the native G-step-0 assertion. `deploy/README.md`: a **`GATEWAY_RECOMMENDED_CLIENT_VERSION`** row (soft tier;
validated ≥ `MIN`). `ui/e2e/native.spec.ts` already reflects the new boot/offline (done in O5).
- Acceptance: the docs describe both tiers + the implicit model + the unified lobby; `deploy/README` lists
`GATEWAY_RECOMMENDED_CLIENT_VERSION`. **Verified** (a full-doc sweep leaves no stale deliberate-offline / terminal /
toggle / dialog reference). Code unchanged from O6 (docs only): gateway Go green, svelte-check 0/0, vitest 617,
e2e 122/122 chromium + webkit.
**Offline-model redesign COMPLETE (O1O7).** G-step-0 is resolved — the redesign is contour-safe (both version vars empty
⇒ dormant; the wire add is additive). Next is the normal G chain: PR → `development` → contour verify → `master` → tag →
dispatch `android-build`.
---
## Google Play variant (later — design now so we don't repaint) ## Google Play variant (later — design now so we don't repaint)
One codebase, two build variants selected by flags: One codebase, two build variants selected by flags:
@@ -472,8 +1049,10 @@ target, built later.
## Build & env matrix ## Build & env matrix
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`): **Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var). - `VITE_GATEWAY_URL` — absolute gateway origin; the workflow reuses `vars.PROD_PUBLIC_BASE_URL`
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target. (`https://erudit-game.ru`, trailing slash stripped). The native-critical var.
- `VITE_RUSTORE_URL` — the update-overlay store target. **Empty in the MVP** (set the `ANDROID_RUSTORE_URL`
Gitea variable once published); the gate is dormant so the button never fires yet.
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header. - `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_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.) - `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
@@ -516,7 +1095,10 @@ breaking release deliberately sets the min version.
Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app 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 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 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. notifications (FCM), and automated RuStore-API upload.
## End-to-end verification ## End-to-end verification
+7
View File
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
TELEGRAM_TEST_ENV=false TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL= TELEGRAM_API_BASE_URL=
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
# variables, shared across contours; in the test contour the stamped client version is a commit hash
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
GATEWAY_MIN_CLIENT_VERSION=
GATEWAY_RECOMMENDED_CLIENT_VERSION=
# --- VK Mini App ------------------------------------------------------------ # --- VK Mini App ------------------------------------------------------------
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App # The VK app's "protected key" (client_secret): the gateway verifies the Mini App
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call). # launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
+28
View File
@@ -118,6 +118,8 @@ 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_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_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_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)_ | **Hard** tier of the client-version gate (ARCHITECTURE.md §2). Minimum client build the gateway will serve. 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 — the client then degrades to an offline "Update / Play offline" notice, not a hard lockout. Validated at load; a non-empty unparseable value fails startup. |
| `GATEWAY_RECOMMENDED_CLIENT_VERSION` | variable | _(empty)_ | **Soft** tier of the client-version gate (ARCHITECTURE.md §2). A build at or above `GATEWAY_MIN_CLIENT_VERSION` but **below** this is served normally, but every gated `Execute` response carries an additive `X-Update-Recommended: 1` header ⇒ the client shows a dismissable *update available* nudge (play continues). Empty ⇒ off. Validated at load: unparseable, or **below** `GATEWAY_MIN_CLIENT_VERSION`, fails startup. Bump it (ahead of `MIN`) to nudge upgrades before a hard cut-over. |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `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_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). | | `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
@@ -236,6 +238,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 **point-in-time recovery** (below) is the primary recovery path once armed, and the only one
that survives losing the host. 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) ## Point-in-time recovery (PITR)
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3** The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
+6
View File
@@ -222,6 +222,12 @@ services:
GATEWAY_HTTP_ADDR: ":8081" GATEWAY_HTTP_ADDR: ":8081"
GATEWAY_BACKEND_HTTP_URL: http://backend:8080 GATEWAY_BACKEND_HTTP_URL: http://backend:8080
GATEWAY_BACKEND_GRPC_ADDR: backend:9090 GATEWAY_BACKEND_GRPC_ADDR: backend:9090
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
# Telegram auth validates against the home validator (plaintext, internal). # Telegram auth validates against the home validator (plaintext, internal).
GATEWAY_VALIDATOR_ADDR: validator:9091 GATEWAY_VALIDATOR_ADDR: validator:9091
# VK Mini App auth verifies the launch-parameter signature in-process under the VK # VK Mini App auth verifies the launch-parameter signature in-process under the VK
+2
View File
@@ -49,6 +49,8 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}' export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION' export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$APP_VERSION' export APP_VERSION='$APP_VERSION'
export GATEWAY_MIN_CLIENT_VERSION='$GATEWAY_MIN_CLIENT_VERSION'
export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN' export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET' export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
+108 -37
View File
@@ -100,15 +100,26 @@ dropped). Horizontal scaling is explicit future work.
auth operations are unauthenticated and return the minted token. A unary auth operations are unauthenticated and return the minted token. A unary
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal) 200); only edge failures (rate limit, missing session, unknown type, internal)
surface as Connect error codes. The client treats a connectivity edge failure as surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
actions, and the transport **auto-retries with capped exponential backoff** — every op on a states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only** the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
response was lost — its button is disabled while offline and the player re-issues it on reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal. `@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
local lobby, no transport kill switch and no device-local create paths.
**Edge hardening:** every request body on the public listener is capped at **Edge hardening:** every request body on the public listener is capped at
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP `GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
@@ -140,6 +151,40 @@ dropped). Horizontal scaling is explicit future work.
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the (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 client's in-app stream while the app is open. Out-of-app delivery uses
platform-native push via the platform side-service. platform-native push via the platform side-service.
- **Client-version gate — two tiers** (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`/`Subscribe` check it at the top,
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
vars empty ⇒ off, web behaviour unchanged).
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
`offlineVersionLocked` (offline mode) and shows a **dismissable notice****"Update"** (native → the
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **`min`**
(validated at config load); empty ⇒ the soft tier is off.
- **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): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
**user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
rather than interrupting local play.
## 3. Authentication & sessions ## 3. Authentication & sessions
@@ -224,6 +269,19 @@ arrive from a platform rather than completing a mandatory registration).
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate. `BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
Platform and email users are auto-provisioned **durable** accounts with an Platform and email users are auto-provisioned **durable** accounts with an
identity. 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 > **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 > two-bot model (one bot per service language, with `accounts.service_language`, a
@@ -1332,13 +1390,18 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
cache-first — the old build's version until a second load. The landing page and the conditional cache-first — the old build's version until a second load. The landing page and the conditional
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with (§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those (the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
creates one through the in-browser engine — the same game screen then drives it, the robot replying **server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**. friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
pass-and-play (hotseat)** — with the online segment disabled and offline forced when there is no network;
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
hotseat create is guarded when
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
the in-browser engine and driven by the same game screen, the robot replying locally.
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` + A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the (referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
@@ -1366,27 +1429,17 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried — on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
mode** boots from the persisted session and on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session === false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
adoption and profile fetch that would otherwise hang with no network, and lands straight in the the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag **hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
while offline. The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are `immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An `no-cache` so a new deploy is picked up — both containers apply the same caching. An
@@ -1468,6 +1521,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 client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
contour activates; the prod main host omits it. 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/<variant>@<version>.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 ## 14. CI & branches
- **Two long-lived branches**: **`development`** is the integration - **Two long-lived branches**: **`development`** is the integration
+46 -23
View File
@@ -277,22 +277,28 @@ add-friend are off. AI games are **practice** — they never count toward a play
statistics. statistics.
### Offline mode ### Offline mode
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode** The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are native apps only). When it cannot reach the
disabled or hidden (the Stats tab; the *random opponent* option in New Game). server the header turns blue with an *Offline* chip and play is confined to games on the device; a
A New Game against the robot creates a device-local **vs_ai** game — it momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
plays entirely in the browser with no backend. Local games are kept on the device, visible only in connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
offline mode, and never sync to the account. So a game can be created and played with no connection, app never touches the network.
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
(once installed) launches from a precached shell even with no network. Switching **to** offline first
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
(the download keeps running in the background, so a later switch is instant). The mode is
device-scoped and sticky across launches.
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one The **lobby stays unified**: your device-local games are active, while your server games are still
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style listed but **greyed out** — visible but un-openable until you are back online (a tap explains why).
Online-only surfaces (the Stats tab) are disabled and invitations are hidden while offline. A New Game
against the robot creates a device-local **vs_ai** game that plays entirely on the device with no
backend; local games are kept on the device and never sync to the account, so a game can be created and
played with no connection. The app quietly preloads the dictionaries for the player's enabled variants
while still online, and (once installed, or on the native app) launches from bundled/precached data even
with no network; if a variant's dictionary is not available offline, creating that game is disabled with
a short note.
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
(In the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too — keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**. rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
@@ -305,14 +311,31 @@ pass-and-play game. A game that finishes normally is saved to the offline lobby
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password, deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
so no one can wipe a game the moment they have moved. so no one can wipe a game the moment they have moved.
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch Going offline and coming back are both **automatic**. On a cold launch with no connection the app
with no connection it switches to offline for that session; when the device is online but the gateway starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with offline on its own, and restoring the network returns it online by itself. There is no dialog and no
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
the network — airplane mode — switches to offline automatically, and restoring it returns online by offline, so you are never interrupted for a hiccup.
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that ### Staying up to date
dialog — is the player's choice: it is kept, and never undone automatically. When a new client version is required, the app does not lock you out. On the **native** app a
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
playing your on-device games); on the web it offers **Update** (reload) instead. A softer,
non-blocking **"update available"** banner appears in the lobby when a newer — but not yet
required — version exists; you can update or dismiss it, and play continues either way.
### 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 ### Social: friends, block, chat, nudge
Become friends in two ways: redeem a **one-time code** the other player issues (six Become friends in two ways: redeem a **one-time code** the other player issues (six
+46 -23
View File
@@ -283,22 +283,27 @@ e-mail) либо ввод фразы. Активные игры форфейтя
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока. редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим ### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный **Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с **мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности приложениям). Когда оно не может достучаться
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре). до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение сети.
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек **Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле **затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает, экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
@@ -312,14 +317,32 @@ e-mail) либо ввод фразы. Активные игры форфейтя
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода. своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить** восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети — кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн. вас не прерывают из-за короткой икоты.
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том ### Актуальная версия
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически. Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
### Нативное приложение (Android)
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
офлайн-игры.
### Социальное: друзья, блок, чат, nudge ### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
+37 -5
View File
@@ -17,21 +17,43 @@ tests or touching CI.
- **UI** — Vitest (unit) + Playwright - **UI** — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
the FlatBuffers codecs (friend list, invitation, stats), the win-rate the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/copy/download choice, plus Playwright specs against the derivation, the GCG share/copy/download choice and the **net-state reducer**
(`netstate.test.ts` — every transition of the connectivity/version machine, the anti-flap
hysteresis, the two-tier version decision and all 12 offline edge cases as pure assertions),
plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the export chooser's invitations section, the stats screen, profile editing, and the export chooser's
finished-only visibility + its signed-URL download flow (route-intercepted). The finished-only visibility + its signed-URL download flow (route-intercepted). The
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game **offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
end-to-end: it forces the installed-PWA display mode, enters offline through the end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the Stats tab), self-heals back online, then creates and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
rack is deterministic and the human can tap out a precomputed opening), asserting the rack is deterministic and the human can tap out a precomputed opening), asserting the
robot's real reply and the IndexedDB replay after a reload. A local game needs a real robot's real reply and the IndexedDB replay after a reload. A local game needs a real
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui` 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 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 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 — the device-local game stays listed in the
**unified lobby** (closing G-step-0) — and Profile hides the Telegram/VK link buttons.
The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
update_required` mapping.
- **Client-version gate** (Go, two tiers) — `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 the **hard** tier — 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) — and the **soft** tier (`TestExecuteUpdateRecommended`):
a served build in `min ≤ v < recommended` carries the `X-Update-Recommended: 1` response header, while a
too-old, up-to-date, absent/garbled, or dormant-tier request does not. `config` tests reject a non-empty
unparseable `GATEWAY_MIN_CLIENT_VERSION` / `GATEWAY_RECOMMENDED_CLIENT_VERSION`, and a recommended below the
minimum.
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared - **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a `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 real self-played 35-move game) must rasterize to a plausible PNG
@@ -175,6 +197,16 @@ tests or touching CI.
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply `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 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. 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 ## Principles
+8 -3
View File
@@ -42,10 +42,15 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_APP_VERSION=$VITE_APP_VERSION \ VITE_APP_VERSION=$VITE_APP_VERSION \
VITE_ADS_STUB=$VITE_ADS_STUB VITE_ADS_STUB=$VITE_ADS_STUB
# Install with the lockfile first (the workspace file carries pnpm's build-script # Install with the lockfile first, then build. Committed src/gen/ means no codegen here.
# approval for esbuild), then build. Committed src/gen/ means no codegen here. # --ignore-scripts: the Vite build needs no dependency build script — esbuild's platform binary
# ships as an optional dependency (only its CLI-shim postinstall is skipped, which Vite does not use),
# and core-js-bundle's prebuilt file is read directly. It notably skips sharp's native build (a
# `pnpm android:assets` tool via @capacitor/assets, unused here), which would otherwise fail on this
# Alpine/musl stage with no prebuilt binary and no compiler. The workspace allowBuilds stay for local
# installs (where android:assets does need sharp built).
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./ COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile --ignore-scripts
COPY ui ./ COPY ui ./
RUN pnpm build RUN pnpm build
+18 -16
View File
@@ -221,22 +221,24 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
} }
registry := transcode.NewRegistry(backend, validator, regOpts...) registry := transcode.NewRegistry(backend, validator, regOpts...)
edge := connectsrv.NewServer(connectsrv.Deps{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry, Registry: registry,
Sessions: sessions, Sessions: sessions,
Backend: backend, Backend: backend,
Limiter: limiter, Limiter: limiter,
Tracker: tracker, Tracker: tracker,
Banlist: banlist, Banlist: banlist,
Blocklist: blocklist, Blocklist: blocklist,
Honeytoken: cfg.Abuse.Honeytoken, Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret, VKAppSecret: cfg.VKAppSecret,
Hub: hub, Hub: hub,
RateLimit: cfg.RateLimit, RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval, Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger, Logger: logger,
AdminProxy: adminProxy, AdminProxy: adminProxy,
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"), Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
MaxBodyBytes: cfg.MaxBodyBytes, MaxBodyBytes: cfg.MaxBodyBytes,
MinClientVersion: cfg.MinClientVersion,
RecommendedClientVersion: cfg.RecommendedClientVersion,
}) })
// Bridge the backend push stream into the fan-out hub (and the out-of-app // Bridge the backend push stream into the fan-out hub (and the out-of-app
+57
View File
@@ -0,0 +1,57 @@
// Package clientver parses and compares the leading MAJOR.MINOR.PATCH of a client
// version string so the edge can turn away a build too old to speak the current wire
// contract. It is deliberately dependency-free and tolerant: the version rides an HTTP
// header (X-Client-Version) that a build stamps from `git describe --tags`, so any
// `-N-gSHA` or `+meta` suffix is ignored, and anything unparseable is reported as such
// (the caller fails open — an absent or garbled header is never treated as too old).
package clientver
import (
"strconv"
"strings"
)
// Version is a parsed semantic version triple. Only MAJOR.MINOR.PATCH participate in the
// ordering; any pre-release or build suffix is dropped at parse time.
type Version struct {
Major, Minor, Patch int
}
// Parse extracts the leading MAJOR.MINOR.PATCH from s, tolerating an optional leading
// "v" and any `-N-gSHA` or `+meta` suffix (as produced by `git describe --tags`). It
// reports ok=false when s has fewer than three numeric components or any component is not
// an integer, so the caller can distinguish a real version from a dev/empty string.
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
}
// Less reports whether a orders before b by MAJOR, then MINOR, then PATCH. Equal versions
// are not Less than each other, so a client exactly at the minimum passes the gate.
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
}
@@ -0,0 +1,63 @@
package clientver
import "testing"
// TestParse covers the version strings the edge actually sees: a plain and a "v"-prefixed
// triple, a `git describe --tags` suffix, build metadata, surrounding space, and the
// non-version strings (dev/empty/too-few/non-numeric) that must report ok=false so the
// gate fails open.
func TestParse(t *testing.T) {
tests := []struct {
name string
in string
want Version
wantK bool
}{
{"plain", "1.16.0", Version{1, 16, 0}, true},
{"v-prefixed", "v1.16.0", Version{1, 16, 0}, true},
{"git describe suffix", "v1.16.0-3-gabc1234", Version{1, 16, 0}, true},
{"build metadata", "1.16.0+ci42", Version{1, 16, 0}, true},
{"surrounding space", " v2.0.1 ", Version{2, 0, 1}, true},
{"extra component ignored", "v1.16.0.4", Version{1, 16, 0}, true},
{"dev", "dev", Version{}, false},
{"empty", "", Version{}, false},
{"too few components", "1.16", Version{}, false},
{"non-numeric patch", "1.16.x", Version{}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := Parse(tc.in)
if ok != tc.wantK {
t.Fatalf("Parse(%q) ok = %v, want %v", tc.in, ok, tc.wantK)
}
if ok && got != tc.want {
t.Errorf("Parse(%q) = %+v, want %+v", tc.in, got, tc.want)
}
})
}
}
// TestLess covers the ordering by MAJOR, then MINOR, then PATCH, and that an equal version
// is not Less (a client exactly at the minimum passes the gate).
func TestLess(t *testing.T) {
tests := []struct {
name string
a, b Version
want bool
}{
{"equal", Version{1, 16, 0}, Version{1, 16, 0}, false},
{"major less", Version{1, 9, 9}, Version{2, 0, 0}, true},
{"major greater", Version{2, 0, 0}, Version{1, 9, 9}, false},
{"minor less", Version{1, 16, 5}, Version{1, 17, 0}, true},
{"minor greater", Version{1, 17, 0}, Version{1, 16, 9}, false},
{"patch less", Version{1, 16, 0}, Version{1, 16, 1}, true},
{"patch greater", Version{1, 16, 2}, Version{1, 16, 1}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Less(tc.a, tc.b); got != tc.want {
t.Errorf("Less(%+v, %+v) = %v, want %v", tc.a, tc.b, got, tc.want)
}
})
}
}
+38 -8
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"scrabble/gateway/internal/clientver"
pkgtel "scrabble/pkg/telemetry" pkgtel "scrabble/pkg/telemetry"
) )
@@ -54,6 +55,16 @@ type Config struct {
// MaxBodyBytes caps one inbound request body on the public listener and one // MaxBodyBytes caps one inbound request body on the public listener and one
// Connect message read; oversized requests are refused without buffering. // Connect message read; oversized requests are refused without buffering.
MaxBodyBytes int MaxBodyBytes int
// MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
// the edge serves. A client reporting an older X-Client-Version is turned away with an
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
// (every client is served) — the default for web-only deployments.
MinClientVersion string
// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
RecommendedClientVersion string
// RateLimit configures the in-memory anti-abuse limiter. // RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only). // Abuse configures the temporary IP ban and the honeytoken (prod-only).
@@ -226,14 +237,16 @@ func (c VKIDConfig) Enabled() bool {
func Load() (Config, error) { func Load() (Config, error) {
var err error var err error
c := Config{ c := Config{
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr), HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel), LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL), BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr), BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"), AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"), ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"), VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
VKID: VKIDConfig{ VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"), AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"), ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
@@ -332,6 +345,23 @@ func (c Config) validate() error {
if c.MaxBodyBytes <= 0 { if c.MaxBodyBytes <= 0 {
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive") return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
} }
if c.MinClientVersion != "" {
if _, ok := clientver.Parse(c.MinClientVersion); !ok {
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
}
}
if c.RecommendedClientVersion != "" {
rec, ok := clientver.Parse(c.RecommendedClientVersion)
if !ok {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
}
// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
// misconfiguration (every client below min is already turned away). An empty/unparseable
// minimum imposes no lower bound, so the recommended may stand alone.
if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
}
}
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) { if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED") return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
} }
+59
View File
@@ -47,6 +47,65 @@ func TestLoadMaxBodyBytes(t *testing.T) {
} }
} }
// TestLoadMinClientVersion verifies the client-version gate config: dormant (empty) by
// default, a parseable version accepted, and an unparseable one rejected.
func TestLoadMinClientVersion(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.MinClientVersion != "" {
t.Errorf("MinClientVersion = %q, want empty (gate dormant)", c.MinClientVersion)
}
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
if c, err = Load(); err != nil {
t.Fatalf("Load with a valid min version: %v", err)
}
if c.MinClientVersion != "v1.16.0" {
t.Errorf("MinClientVersion = %q, want %q", c.MinClientVersion, "v1.16.0")
}
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "dev")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unparseable GATEWAY_MIN_CLIENT_VERSION, got nil")
}
}
// TestLoadRecommendedClientVersion verifies the soft-tier config: dormant (empty) by default, a
// parseable version accepted (standing alone with no minimum, or at/above one), an unparseable one
// rejected, and a recommended below the minimum rejected.
func TestLoadRecommendedClientVersion(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.RecommendedClientVersion != "" {
t.Errorf("RecommendedClientVersion = %q, want empty (soft tier dormant)", c.RecommendedClientVersion)
}
// Stands alone with no minimum configured.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.20.0")
if c, err = Load(); err != nil {
t.Fatalf("Load with a valid recommended version (no min): %v", err)
}
if c.RecommendedClientVersion != "v1.20.0" {
t.Errorf("RecommendedClientVersion = %q, want %q", c.RecommendedClientVersion, "v1.20.0")
}
// At or above the minimum is accepted.
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
if _, err = Load(); err != nil {
t.Fatalf("Load with recommended >= min: %v", err)
}
// Below the minimum is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.10.0")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a recommended version below the minimum, got nil")
}
// Unparseable is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "dev")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unparseable GATEWAY_RECOMMENDED_CLIENT_VERSION, got nil")
}
}
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only), // TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
// the agreed thresholds, and no honeytoken. // the agreed thresholds, and no honeytoken.
func TestLoadAbuseDefaults(t *testing.T) { func TestLoadAbuseDefaults(t *testing.T) {
+1
View File
@@ -12,6 +12,7 @@ var (
errInternal = errors.New("internal error") errInternal = errors.New("internal error")
errMissingToken = errors.New("missing session token") errMissingToken = errors.New("missing session token")
errInvalidSession = errors.New("invalid or expired session") errInvalidSession = errors.New("invalid or expired session")
errUpdateRequired = errors.New("client too old, update required")
) )
// errUnknownMessageType reports an unregistered message type. // errUnknownMessageType reports an unregistered message type.
+124 -1
View File
@@ -26,6 +26,7 @@ import (
"golang.org/x/net/http2/h2c" "golang.org/x/net/http2/h2c"
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/clientver"
"scrabble/gateway/internal/config" "scrabble/gateway/internal/config"
"scrabble/gateway/internal/push" "scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/ratelimit"
@@ -46,6 +47,21 @@ const heartbeatKind = "heartbeat"
// spoofer, so trusting it is safe. // spoofer, so trusting it is safe.
const honeypotHeader = "X-Scrabble-Honeypot" const honeypotHeader = "X-Scrabble-Honeypot"
// clientVersionHeader carries the client build version (stamped from `git describe --tags`) so
// the edge can turn away a build too old to speak the current wire contract, before it decodes
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
// "you must update". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
// on a served Execute response when the client is at or above the hard minimum but below the
// recommended version, it nudges an update without failing the call. Headers are the
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
// contract (docs/ARCHITECTURE.md §2).
const (
clientVersionHeader = "X-Client-Version"
resultUpdateRequired = "update_required"
updateRecommendedHeader = "X-Update-Recommended"
)
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the // Limiter classes, the `class` attribute of gateway_rate_limited_total and the
// class field of the periodic rejection report. // class field of the periodic rejection report.
const ( const (
@@ -88,6 +104,17 @@ type Server struct {
maxBodyBytes int maxBodyBytes int
// minClient is the lowest client version served; gateOn is false when the gate is dormant
// (GATEWAY_MIN_CLIENT_VERSION empty or unparseable).
minClient clientver.Version
gateOn bool
// recClient is the version below which a served client is nudged to update (the soft tier);
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
// unparseable). It is independent of the hard gate.
recClient clientver.Version
recOn bool
publicPolicy ratelimit.Policy publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy emailPolicy ratelimit.Policy
@@ -128,6 +155,13 @@ type Deps struct {
// MaxBodyBytes caps one inbound request body and one Connect message read; // MaxBodyBytes caps one inbound request body and one Connect message read;
// zero or negative selects config.DefaultMaxBodyBytes. // zero or negative selects config.DefaultMaxBodyBytes.
MaxBodyBytes int MaxBodyBytes int
// MinClientVersion is the lowest client version (MAJOR.MINOR.PATCH) the edge serves; an
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
// the gate dormant.
MinClientVersion string
// RecommendedClientVersion is the version below which a served client is nudged to update (the
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
RecommendedClientVersion string
} }
// NewServer constructs the edge service. // NewServer constructs the edge service.
@@ -160,6 +194,30 @@ func NewServer(d Deps) *Server {
if rl == (config.RateLimitConfig{}) { if rl == (config.RateLimitConfig{}) {
rl = config.DefaultRateLimit() rl = config.DefaultRateLimit()
} }
// Parse the minimum client version once. Config.validate already rejects an unparseable
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
// the gate dormant.
var minClient clientver.Version
gateOn := false
if d.MinClientVersion != "" {
if v, ok := clientver.Parse(d.MinClientVersion); ok {
minClient, gateOn = v, true
} else {
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
}
}
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
// construction; an empty value leaves the soft tier dormant.
var recClient clientver.Version
recOn := false
if d.RecommendedClientVersion != "" {
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
recClient, recOn = v, true
} else {
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
}
}
return &Server{ return &Server{
registry: d.Registry, registry: d.Registry,
sessions: d.Sessions, sessions: d.Sessions,
@@ -176,6 +234,10 @@ func NewServer(d Deps) *Server {
adminProxy: d.AdminProxy, adminProxy: d.AdminProxy,
metrics: newServerMetrics(d.Meter, blocklist), metrics: newServerMetrics(d.Meter, blocklist),
maxBodyBytes: maxBody, maxBodyBytes: maxBody,
minClient: minClient,
gateOn: gateOn,
recClient: recClient,
recOn: recOn,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst), publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst), userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst), emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
@@ -285,15 +347,71 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
}) })
} }
// clientTooOld reports whether the X-Client-Version header names a version below the configured
// minimum. It fails open: with the gate dormant, or an absent or unparseable header, it returns
// false. The header is a client-controlled compatibility signal, not an access control (it is
// trivially spoofable), so a missing or garbled value — an old build predating the header, or a
// non-browser caller — must never be blocked spuriously.
func (s *Server) clientTooOld(header string) bool {
if !s.gateOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return clientver.Less(v, s.minClient)
}
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
// band — at or above the hard minimum but below the recommended version — so a served response can
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
// leaves minClient at the zero version, which no real version is below, so the band is simply
// "below recommended").
func (s *Server) clientUpdateRecommended(header string) bool {
if !s.recOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
}
// Execute runs one unary operation. Domain failures are returned in the envelope // Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing // (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors. // session, unknown type, internal) become Connect errors.
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) { func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
start := time.Now() start := time.Now()
msgType := req.Msg.GetMessageType() msgType := req.Msg.GetMessageType()
result := "internal" result := "internal"
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }() defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
// client at or above the hard minimum but below the recommended version is told an update is
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
// version yields no nudge.
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
defer func() {
if recommend && resp != nil {
resp.Header().Set(updateRecommendedHeader, "1")
}
}()
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
// the payload is decoded, so a too-old client makes zero successful calls but sees the
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
result = resultUpdateRequired
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: resultUpdateRequired,
}), nil
}
op, ok := s.registry.Lookup(msgType) op, ok := s.registry.Lookup(msgType)
if !ok { if !ok {
result = "unknown_type" result = "unknown_type"
@@ -363,6 +481,11 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
// Subscribe streams the authenticated user's live events with a keep-alive // Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects. // heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error { func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
// A stream carries no result_code, so a too-old client is refused with the Connect
// FailedPrecondition counterpart of the update_required sentinel.
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired)
}
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header())) uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
if err != nil { if err != nil {
return err return err
+166 -7
View File
@@ -22,8 +22,13 @@ import (
) )
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect // newEdge wires a connectsrv.Server over a fake backend and returns a Connect
// client plus a cleanup func. // client plus a cleanup func. The client-version gate is off.
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) { func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
return newEdgeMin(t, "", backendHandler)
}
// newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off).
func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper() t.Helper()
backendSrv := httptest.NewServer(backendHandler) backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second) backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
@@ -31,12 +36,40 @@ func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.Gatew
t.Fatalf("backendclient: %v", err) t.Fatalf("backendclient: %v", err)
} }
edge := connectsrv.NewServer(connectsrv.Deps{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil), Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100), Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(), Limiter: ratelimit.New(),
Hub: push.NewHub(0), Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(), RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second, Heartbeat: 15 * time.Second,
MinClientVersion: minVersion,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
return client, func() {
edgeSrv.Close()
_ = backend.Close()
backendSrv.Close()
}
}
// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
MinClientVersion: minVersion,
RecommendedClientVersion: recommendedVersion,
}) })
edgeSrv := httptest.NewServer(edge.HTTPHandler()) edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL) client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
@@ -108,6 +141,132 @@ func TestExecuteGuestGate(t *testing.T) {
} }
} }
// TestExecuteVersionGate verifies the client-version gate on the unary path: a build older
// than the configured minimum is turned away with the update_required result code before the
// registry lookup (an unknown message type is gated too, proving the short-circuit), while an
// equal, newer, absent, or unparseable version passes; and the gate is dormant when unset.
func TestExecuteVersionGate(t *testing.T) {
// The backend answers auth.guest with a session; a gated call never reaches it.
backend := func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
}
tests := []struct {
name string
min string
header string
msgType string
want string
}{
{"too old is gated before lookup", "v1.16.0", "v1.0.0", "bogus.unknown", "update_required"},
{"equal passes", "v1.16.0", "v1.16.0", transcode.MsgAuthGuest, "ok"},
{"newer passes", "v1.16.0", "v2.0.0", transcode.MsgAuthGuest, "ok"},
{"absent header fails open", "v1.16.0", "", transcode.MsgAuthGuest, "ok"},
{"unparseable header fails open", "v1.16.0", "dev", transcode.MsgAuthGuest, "ok"},
{"gate dormant when unset", "", "v1.0.0", transcode.MsgAuthGuest, "ok"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeMin(t, tc.min, backend)
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: tc.msgType, RequestId: "rq"})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
resp, err := client.Execute(context.Background(), req)
if err != nil {
t.Fatalf("execute: %v", err)
}
if got := resp.Msg.GetResultCode(); got != tc.want {
t.Fatalf("result_code = %q, want %q", got, tc.want)
}
})
}
}
// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
// and a dormant soft tier get no header.
func TestExecuteUpdateRecommended(t *testing.T) {
backend := func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
}
tests := []struct {
name string
min string
rec string
header string
wantResult string
wantHeader bool
}{
{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
resp, err := client.Execute(context.Background(), req)
if err != nil {
t.Fatalf("execute: %v", err)
}
if got := resp.Msg.GetResultCode(); got != tc.wantResult {
t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
}
if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
}
})
}
}
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
// fails Unauthenticated with no session, proving it passed the version gate).
func TestSubscribeVersionGate(t *testing.T) {
noBackend := func(_ http.ResponseWriter, _ *http.Request) {}
tests := []struct {
name string
min string
header string
want connect.Code
}{
{"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition},
{"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated},
{"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeMin(t, tc.min, noBackend)
defer cleanup()
req := connect.NewRequest(&edgev1.SubscribeRequest{})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
stream, err := client.Subscribe(context.Background(), req)
if err == nil {
for stream.Receive() {
}
err = stream.Err()
}
if got := connect.CodeOf(err); got != tc.want {
t.Fatalf("code = %v, want %v", got, tc.want)
}
})
}
}
func TestExecuteAuthedRequiresSession(t *testing.T) { func TestExecuteAuthedRequiresSession(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called without a session") t.Error("backend must not be called without a session")
+5
View File
@@ -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 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/<bot>/<app>`). friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page. `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 The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`). `/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Release signing material must never be committed (see ANDROID_PLAN.md — keystore loss/leak risk).
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+81
View File
@@ -0,0 +1,81 @@
apply plugin: 'com.android.application'
android {
namespace = "ru.eruditgame.app"
compileSdk = rootProject.ext.compileSdkVersion
// Release signing is supplied by the android-build CI workflow via env: it decodes the keystore
// secret to a file and points ANDROID_KEYSTORE_FILE at it. A local build or a keyless CI run leaves
// it unset, so the release APK is produced UNSIGNED rather than failing — assembleDebug and
// assembleRelease both build without the keystore. The keystore is never committed (see .gitignore).
def keystoreFile = System.getenv('ANDROID_KEYSTORE_FILE')
def hasKeystore = keystoreFile != null && !keystoreFile.isEmpty() && file(keystoreFile).exists()
defaultConfig {
applicationId "ru.eruditgame.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Deterministic from the release tag by the android-build workflow (vMA.MI.PA ->
// MA*1_000_000 + MI*1_000 + PA); the defaults keep a local assembleDebug building.
versionCode = (project.findProperty('versionCode') ?: '1').toInteger()
versionName = (project.findProperty('versionName') ?: '0.0.0').toString()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
// Populated only when the workflow provided a keystore (see hasKeystore above); left empty
// otherwise so it is never referenced with a null storeFile.
if (hasKeystore) {
storeFile file(keystoreFile)
storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
keyAlias System.getenv('ANDROID_KEY_ALIAS')
keyPassword System.getenv('ANDROID_KEY_PASSWORD')
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Sign only when a keystore was supplied; a keyless release build stays unsigned instead
// of failing.
if (hasKeystore) {
signingConfig signingConfigs.release
}
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
+20
View File
@@ -0,0 +1,20 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-network')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,5 @@
package ru.eruditgame.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Эрудит</string>
<string name="title_activity_main">Эрудит</string>
<string name="package_name">ru.eruditgame.app</string>
<string name="custom_url_scheme">ru.eruditgame.app</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
+9
View File
@@ -0,0 +1,9 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.4.1_@capacitor+core@8.4.1/node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.4.1/node_modules/@capacitor/app/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/.pnpm/@capacitor+network@8.0.1_@capacitor+core@8.4.1/node_modules/@capacitor/network/android')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}

Some files were not shown because too many files have changed in this diff Show More