feat(offline): implicit net-state model, two-tier version gate, unified lobby #249
@@ -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
|
||||
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)
|
||||
|
||||
- **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.
|
||||
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
|
||||
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).
|
||||
Use **testcontainers** for container-backed tests.
|
||||
- **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
|
||||
@@ -353,6 +353,11 @@ jobs:
|
||||
# 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.
|
||||
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
|
||||
# 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 }}
|
||||
|
||||
@@ -112,6 +112,12 @@ jobs:
|
||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||
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.
|
||||
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||
|
||||
@@ -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) |
|
||||
| appId (permanent) | **`ru.eruditgame.app`** |
|
||||
| App display name | **`Эрудит`** (Cyrillic) |
|
||||
| Toolchain (locked) | **Capacitor 8** (`@capacitor/*` `^8`); **compileSdk/targetSdk 36, minSdk 24**; **JDK 21** (required — `@capacitor/android` compiles at Java 21; AGP 8.13 / Gradle 8.14.3); Android SDK `platforms;android-36` + `build-tools;36.x` |
|
||||
|
||||
**Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code,
|
||||
commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo
|
||||
@@ -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.1–D.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 (O1–O7)** in the "Offline-model redesign" section below.
|
||||
**➡ Next actionable work: implement O1** (the pure `netState` reducer, test-first) via `stage-implementation`,
|
||||
then O2–O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of
|
||||
O1–O7 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)
|
||||
|
||||
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*
|
||||
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.
|
||||
- **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login
|
||||
and the CI watcher exactly as the owner's global setup (`~/.claude` tooling:
|
||||
`python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`).
|
||||
- **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the
|
||||
sibling `scrabble-solver` next to it. The sibling holds the committed dictionaries
|
||||
(`scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg`) that offline-first bundles, and
|
||||
the solver library the backend/CI use.
|
||||
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. Pixel,
|
||||
API 34).
|
||||
sibling `scrabble-solver` next to it — the solver **library** the backend/CI consume via the
|
||||
`go.work` replace. **The bundled dictionaries do NOT come from the solver.** The versioned,
|
||||
production dictionary set is published by `developer/scrabble-dictionary` as a **release artifact**
|
||||
`scrabble-dawg-<DICT_VERSION>.tar.gz` (one semver label per set); `backend/Dockerfile` and every CI
|
||||
job already `curl` exactly that tarball, keyed on the shared Gitea variable `DICT_VERSION`.
|
||||
Offline-first bundles the DAWGs from that same release (see D.1 / E), so the Android build needs
|
||||
`DICT_VERSION` + network access to the release, **not** `scrabble-solver`. (`scrabble-solver/dawg/*.dawg`
|
||||
are the solver's committed test fixtures — byte-identical to a build but pinned to the solver
|
||||
commit, not the versioned production set.)
|
||||
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. a
|
||||
Pixel AVD; any recent API image).
|
||||
- **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway
|
||||
is only needed to exercise the version gate locally (optional).
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`@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/`)
|
||||
`./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`:
|
||||
```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
|
||||
hide the buy actions (grep `isGooglePlayBuild` under `ui/src`). Keep the "go to RuStore" stub
|
||||
`isGooglePlayBuild()`-only. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
|
||||
hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub
|
||||
`isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() &&
|
||||
!isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key
|
||||
`wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the
|
||||
same note can replace the RuStore stub in the later Google Play anti-steering variant.
|
||||
`purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the
|
||||
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_RUSTORE_URL?: string`,
|
||||
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
|
||||
4. **Native env matrix** — see Build & env.
|
||||
|
||||
**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the
|
||||
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
||||
|
||||
### C. The client-version gate
|
||||
### C. The client-version gate — ✅ DONE
|
||||
|
||||
#### C1. Backend (gateway)
|
||||
|
||||
@@ -313,96 +462,194 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
||||
before the throw.
|
||||
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
|
||||
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
|
||||
- **Reconciliation exception:** provide a variant used by background guest-reconciliation that does
|
||||
NOT call `reportUpdateRequired()` (it swallows the code and stays offline). Simplest: a boolean
|
||||
on `exec`/a dedicated `authGuestSilent` path guarded by a flag; the executing session picks the
|
||||
seam. Foreground `auth.*` keep the overlay behaviour.
|
||||
- **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant
|
||||
(swallows `update_required` without raising the overlay) has no caller until D.4, so it is added
|
||||
there with its caller rather than speculatively in C. In C every foreground call that gets
|
||||
`update_required` raises the overlay; offline play never trips it (the network kill switch).
|
||||
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
|
||||
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
|
||||
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
|
||||
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
|
||||
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys
|
||||
`window.open(import.meta.env.VITE_RUSTORE_URL, '_system')`; web → `location.reload()`. i18n keys
|
||||
`update.title/body/action` (add siblings to the maintenance keys).
|
||||
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
|
||||
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
|
||||
- **Tests:** `update.svelte.test.ts`; extend `retry.test.ts` (`FailedPrecondition → 'update_required'`);
|
||||
Playwright `update.spec.ts` driving `__update.on()`.
|
||||
- **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via
|
||||
Playwright `update.spec.ts` (`__update.on()`). The `update.svelte.ts` store is a `$state` rune module,
|
||||
which this project's plugin-less `vitest` cannot import (`$state is not defined`); like every other
|
||||
`*.svelte.ts` store it is covered by the e2e, not a unit test.
|
||||
|
||||
**Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local
|
||||
gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
|
||||
|
||||
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation)
|
||||
### 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
|
||||
hotseat (pass-and-play with a host PIN referee) — see `ui/src/lib/localgame/source.hotseat.test.ts`
|
||||
and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch
|
||||
with no network**.
|
||||
|
||||
1. **Bundle dictionaries in the APK.**
|
||||
- New build step `ui/scripts/bundle-dicts.mjs`: copy `../scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg`
|
||||
→ `ui/dist/dict/<variant>@<DICT_VERSION>.dawg` (dictKey naming). Run only in the native pipeline
|
||||
(after `pnpm build`, before `cap sync`). Web builds stay slim.
|
||||
- Vite `define`: add `__DICT_VERSION__` from `VITE_DICT_VERSION` (mirrors `__APP_VERSION__`);
|
||||
declare it in `vite-env.d.ts`.
|
||||
- **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: between the IndexedDB tier (line 73-85)
|
||||
and the network tier (line 87), add a **bundled tier** that `fetch('./dict/'+key+'.dawg')`
|
||||
(relative — served from app assets on native, 404 on web → falls through). On a hit, build the
|
||||
`Dawg`, cache in memory, seed IndexedDB (`idbPutDawg`), and return. This is the only change that
|
||||
lets a never-online device obtain a dictionary.
|
||||
- Offline local-game creation must request the **bundled version**: where `NewGame.svelte`
|
||||
offline-create and `localgame` resolve the dict version, use `__DICT_VERSION__` so the requested
|
||||
`(variant, version)` matches the bundled file (grep the offline `localSource.create` calls in
|
||||
`NewGame.svelte:83-96,289-296` and the dict-version resolution in `gamesource.ts`/`localgame`).
|
||||
2. **Local-guest identity** — new `ui/src/lib/localguest.ts`: a device-local id + default display
|
||||
name (e.g. localized "Гость"), persisted (IndexedDB `scrabble` DB / localStorage). Used as the
|
||||
human seat's account id in a local vs_ai game when there is no server session (today that id comes
|
||||
from the session — see `localgame/source.list.test.ts:64`). Hotseat seats stay independent local
|
||||
identities (already so).
|
||||
3. **Cold offline-first boot** — `ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`:
|
||||
- Extend `shouldBootOffline` (offline.ts:85) so that on a **native** channel, no session may still
|
||||
boot offline as the local guest (dicts are bundled). Keep the current web rule intact (web still
|
||||
needs a prior online session).
|
||||
- In `bootstrap()` (app.svelte.ts): when native and (no cached session) and (offline preference on
|
||||
OR the network is unreachable at boot), set `setOfflineMode(true, …)`, establish the local guest,
|
||||
preload the bundled dicts, and land in the **lobby** (not `Login`). Preserve the existing online
|
||||
path (guest/email) when reachable.
|
||||
- `offlinePreloadEligible` (offline.ts:69): treat a native channel as eligible (drop the
|
||||
`standalone && hasEmail` web gate for native), or bypass preload entirely on native since the
|
||||
dicts are bundled.
|
||||
4. **Lazy server-guest reconciliation** — when native and online and no server session: background
|
||||
`auth.guest` (the silent variant from C2), cache the session, adopt it; unlock online features.
|
||||
Guard against duplicates (only when no cached session). Local games remain device-only. On
|
||||
`update_required`, stay offline silently (no overlay).
|
||||
5. **Ensure both offline modes render for the local guest.** `NewGame.svelte` gates the offline
|
||||
flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both
|
||||
the "quick" (vs_ai) and "with friends" (hotseat) flows show. Verify the vs_ai human seat uses the
|
||||
local-guest id.
|
||||
6. **Soft registration** stays: the local/server guest can register by email → upgrade (existing
|
||||
flow), adopting the server guest first if present.
|
||||
**Status:** D.1–D.6 are **done & verified** (foundations committed `bcd5a1d`; boot + reconciliation +
|
||||
Profile + the Android edge-to-edge safe-area fix committed on `feature/android-native`, 2026-07-12), with
|
||||
`e2e/native.spec.ts` green (chromium + webkit) AND an **on-device emulator smoke** (Pixel_10 / API 37,
|
||||
airplane mode): cold-boot → offline guest lobby, offline vs_ai played end-to-end (bundled dawg — Hint
|
||||
placed "FEZ", the robot replied "NEEDFIRE"), New Game offers both modes. The smoke surfaced a native-chrome
|
||||
bug on **Android 15+ edge-to-edge** (WebView < 140 reports `env(safe-area-inset-*)`=0 → the top nav draws
|
||||
under the status bar and the game's centre Hint button under the gesture-nav home indicator) — **fixed** by
|
||||
consuming the SystemBars core plugin's injected `--safe-area-inset-*`: the bottom via the `--tg-safe-*` token
|
||||
(`app.css`), and the header's top inset via a native-only `.bar` rule (`Header.svelte` — its top inset was
|
||||
Telegram-fullscreen-scoped only, so the token alone did not reach it). No dep/config (the plugin ships in
|
||||
`@capacitor/core` v8); full story in `.claude/CLAUDE.md`. Remaining for D: the reconcile-online leg
|
||||
on-device (hits prod — a guest mint) and the deferred local-game-visibility decision (§G). **Verify every
|
||||
line ref below against current code.**
|
||||
|
||||
**Tests:** extend the local-game/offline unit tests for the bundled-dict tier (a `fetch` mock
|
||||
returning a blob) and the local-guest boot decision (`shouldBootOffline` native-no-session); a
|
||||
Playwright offline-first spec that boots with the network hook off and plays a local vs_ai move and
|
||||
starts a hotseat game. (Follow `docs/TESTING.md` layers; the mock e2e bypasses the codec, so keep the
|
||||
gate's server path in Go tests.)
|
||||
**Decisions locked this session (owner-approved) — bake these before implementing:**
|
||||
- **The blocking Login is bypassed on native only.** Web / PWA / Telegram / VK keep the current
|
||||
online-session rule (they still need a prior session). The native channel always lands the user in the
|
||||
lobby, online or offline.
|
||||
- **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
|
||||
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
|
||||
`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
|
||||
block from `.gitea/workflows/ci.yaml` (the `ui` job). The sibling is needed for the bundled dicts.
|
||||
1. Checkout this repo; copy the Node/pnpm setup block from `.gitea/workflows/ci.yaml` (the `ui` job).
|
||||
Add the **"Fetch dictionary DAWGs"** step verbatim from `ci.yaml` (the `curl …
|
||||
scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz` +
|
||||
untar into `${GITHUB_WORKSPACE}/dawg`), keyed on the `DICT_VERSION` Gitea variable — **this**, not
|
||||
the `scrabble-solver` sibling, is the source of the bundled dicts. (`scrabble-solver` is not needed
|
||||
by the Android build: `ui` is a Node project outside `go.work`.)
|
||||
2. `pnpm install --frozen-lockfile`.
|
||||
3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`,
|
||||
`VITE_DICT_VERSION` = the release dict version.
|
||||
4. `node ui/scripts/bundle-dicts.mjs` (copy the `.dawg` into `ui/dist/dict/`).
|
||||
5. Setup JDK 17 (`actions/setup-java@v4`, temurin 17).
|
||||
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-34`,
|
||||
`build-tools;34.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
|
||||
4. `DICT_DIR=${GITHUB_WORKSPACE}/dawg node ui/scripts/bundle-dicts.mjs` (copy the release `.dawg`
|
||||
files into `ui/dist/dict/`).
|
||||
5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21).
|
||||
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`,
|
||||
`build-tools;36.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
|
||||
*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`.
|
||||
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
|
||||
tag (scheme below).
|
||||
@@ -417,11 +664,14 @@ for the MVP.
|
||||
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
|
||||
`versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`.
|
||||
Wire in `ui/android/app/build.gradle` `defaultConfig`:
|
||||
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`,
|
||||
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release`
|
||||
reading env/props (guarded so a local `assembleDebug` still builds unsigned).
|
||||
`versionCode = (project.findProperty('versionCode') ?: '1').toInteger()`,
|
||||
`versionName = (project.findProperty('versionName') ?: '0.0.0').toString()`, plus a guarded
|
||||
`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** +
|
||||
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
|
||||
guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway;
|
||||
network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped).
|
||||
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`,
|
||||
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value.
|
||||
- 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. 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
|
||||
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
|
||||
ships an incompatible wire change.
|
||||
|
||||
### G. Release + owner handoff
|
||||
|
||||
0. **Land the offline-model redesign — ✅ DONE (O1–O7, 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).
|
||||
2. Promote `development` → `master`; tag `vX.Y.0`.
|
||||
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 (O1–O7).** 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)
|
||||
|
||||
One codebase, two build variants selected by flags:
|
||||
@@ -472,8 +1049,10 @@ target, built later.
|
||||
## Build & env matrix
|
||||
|
||||
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
|
||||
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var).
|
||||
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target.
|
||||
- `VITE_GATEWAY_URL` — absolute gateway origin; the workflow reuses `vars.PROD_PUBLIC_BASE_URL`
|
||||
(`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_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.)
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
## End-to-end verification
|
||||
|
||||
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
|
||||
TELEGRAM_TEST_ENV=false
|
||||
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 ------------------------------------------------------------
|
||||
# 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).
|
||||
|
||||
@@ -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_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
|
||||
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
|
||||
| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | **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). |
|
||||
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||
@@ -236,6 +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
|
||||
that survives losing the host.
|
||||
|
||||
## Android app build & release (RuStore)
|
||||
|
||||
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
|
||||
web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md).
|
||||
|
||||
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
|
||||
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
|
||||
but a clean `vMAJOR.MINOR.PATCH` and derives `versionCode = MA*1_000_000 + MI*1_000 + PA`,
|
||||
`versionName = MA.MI.PA`. Watch it green with `python3 ~/.claude/bin/gitea-ci-watch.py`.
|
||||
- **Output:** a `release APK` run artifact — **signed** when the signing secrets are present, an
|
||||
**unsigned** release APK otherwise (a dry run still proves the pipeline).
|
||||
- **Runner (host-executor):** JDK 21 comes from `setup-java`; the **Android SDK is host-provisioned** —
|
||||
install it once (`sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0'`) and grant the
|
||||
`runner` user read+exec (`sudo chmod -R a+rX /opt/android-sdk`). Override the path with the
|
||||
`ANDROID_SDK_DIR` variable. The workflow's `Verify the host Android SDK` step fails fast with the fix if
|
||||
the SDK is missing or unreadable.
|
||||
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
|
||||
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
|
||||
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
||||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
|
||||
variable to the store listing once published (empty until then — the in-app update button no-ops).
|
||||
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
|
||||
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
|
||||
away cleanly instead of failing blind.
|
||||
- **Upload:** download the artifact and upload it to RuStore by hand (no automated RuStore-API upload in the MVP).
|
||||
|
||||
## Point-in-time recovery (PITR)
|
||||
|
||||
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
|
||||
|
||||
@@ -222,6 +222,12 @@ services:
|
||||
GATEWAY_HTTP_ADDR: ":8081"
|
||||
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
||||
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).
|
||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||
|
||||
@@ -49,6 +49,8 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||
export DICT_VERSION='$DICT_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_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
|
||||
@@ -100,15 +100,26 @@ dropped). Horizontal scaling is explicit future work.
|
||||
auth operations are unauthenticated and return the minted token. A unary
|
||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||
surface as Connect error codes. The client treats a connectivity edge failure as
|
||||
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
||||
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
||||
actions, and 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 the player re-issues it on
|
||||
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
||||
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
||||
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
|
||||
via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
|
||||
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
|
||||
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
|
||||
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
|
||||
`@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
|
||||
`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
|
||||
@@ -140,6 +151,40 @@ dropped). Horizontal scaling is explicit future work.
|
||||
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
||||
client's in-app stream while the app is open. Out-of-app delivery uses
|
||||
platform-native push via the platform side-service.
|
||||
- **Client-version gate — 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
|
||||
|
||||
@@ -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.
|
||||
Platform and email users are auto-provisioned **durable** accounts with an
|
||||
identity.
|
||||
- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play
|
||||
identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated
|
||||
id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first
|
||||
launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local
|
||||
games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest`
|
||||
row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session
|
||||
cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It
|
||||
unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server
|
||||
session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an
|
||||
`update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2).
|
||||
**Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity
|
||||
transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the
|
||||
prior online-session rule.
|
||||
|
||||
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
|
||||
> two-bot model (one bot per service language, with `accounts.service_language`, a
|
||||
@@ -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
|
||||
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
|
||||
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
||||
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
||||
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
|
||||
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
|
||||
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
|
||||
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
|
||||
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
|
||||
invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
|
||||
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` +
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||
mode** boots from the persisted session and
|
||||
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
||||
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
||||
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
||||
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
|
||||
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
|
||||
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
|
||||
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
|
||||
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
|
||||
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
|
||||
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
|
||||
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
|
||||
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
|
||||
back-online toast) when the network returns. 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). The gateway registers the `.webmanifest` MIME type
|
||||
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
|
||||
`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
|
||||
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
|
||||
|
||||
- **Two long-lived branches**: **`development`** is the integration
|
||||
|
||||
@@ -277,22 +277,28 @@ add-friend are off. AI games are **practice** — they never count toward a play
|
||||
statistics.
|
||||
|
||||
### Offline mode
|
||||
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
|
||||
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
|
||||
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
||||
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
||||
A New Game against the robot creates a device-local **vs_ai** game — it
|
||||
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
||||
offline mode, 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) 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.
|
||||
The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
|
||||
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
|
||||
native apps only). When it cannot reach the
|
||||
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
|
||||
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
|
||||
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
|
||||
app never touches the network.
|
||||
|
||||
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
|
||||
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||
The **lobby stays unified**: your device-local games are active, while your server games are still
|
||||
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 —
|
||||
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**.
|
||||
@@ -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,
|
||||
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
|
||||
with no connection it switches to offline for that session; when the device is online but the gateway
|
||||
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
||||
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
|
||||
the network — airplane mode — switches to offline automatically, and restoring it returns online by
|
||||
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
|
||||
dialog — is the player's choice: it is kept, and never undone automatically.
|
||||
Going offline and coming back are both **automatic**. On a cold launch with no connection the app
|
||||
starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
|
||||
offline on its own, and restoring the network returns it online by itself. There is no dialog and no
|
||||
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
||||
offline, so you are never interrupted for a hiccup.
|
||||
|
||||
### Staying up to date
|
||||
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
|
||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||
|
||||
@@ -283,22 +283,27 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Офлайн-режим
|
||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||
сохраняется между запусками.
|
||||
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
|
||||
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
|
||||
приложениям). Когда оно не может достучаться
|
||||
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
|
||||
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
|
||||
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
|
||||
сети.
|
||||
|
||||
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
|
||||
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
|
||||
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
|
||||
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
|
||||
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
|
||||
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
|
||||
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
|
||||
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
|
||||
|
||||
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
|
||||
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
|
||||
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
|
||||
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||
@@ -312,14 +317,32 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||
своего хода.
|
||||
|
||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
||||
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
||||
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
|
||||
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
|
||||
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
|
||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||
вас не прерывают из-за короткой икоты.
|
||||
|
||||
### Актуальная версия
|
||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
|
||||
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
|
||||
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
|
||||
|
||||
### Нативное приложение (Android)
|
||||
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
|
||||
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
|
||||
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
|
||||
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
|
||||
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
|
||||
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
|
||||
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
|
||||
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
|
||||
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
|
||||
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
|
||||
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
|
||||
офлайн-игры.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
|
||||
@@ -17,21 +17,43 @@ tests or touching CI.
|
||||
- **UI** — Vitest (unit) + Playwright
|
||||
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
||||
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
|
||||
invitations section, the stats screen, profile editing, and the export chooser's
|
||||
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
|
||||
end-to-end: it forces the installed-PWA display mode, enters offline through the
|
||||
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
|
||||
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
||||
end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
|
||||
auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
|
||||
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
|
||||
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
|
||||
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
|
||||
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
|
||||
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
|
||||
production build.
|
||||
production build. The **native offline-first** spec (`e2e/native.spec.ts`) injects
|
||||
`window.androidBridge` (so `@capacitor/core` resolves the platform to `android` — a bare
|
||||
`Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands
|
||||
in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier
|
||||
(`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
|
||||
`window.__native.reconcile()` to prove online lights up — 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
|
||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
||||
real self-played 35-move game) must rasterize to a plausible PNG
|
||||
@@ -175,6 +197,16 @@ tests or touching CI.
|
||||
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
|
||||
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
|
||||
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
|
||||
- **Native Android (manual on-device smoke)** — the packaged APK is verified by hand on a device /
|
||||
emulator before release (the automated e2e covers the offline-first logic; WebView-specific chrome and
|
||||
store behaviour are not reproducible in Playwright). Checklist: installs and cold-launches; in
|
||||
**airplane mode** the cold launch lands in the **guest lobby**; play a local **vs_ai** move and a
|
||||
**2-player hotseat** game with no network; the hardware **Back** button navigates then exits at the
|
||||
root; a share / export link resolves to `erudit-game.ru` (not `file://`); turning the **network on**
|
||||
lights up online play (a server guest is established); Profile offers **email** sign-in but no
|
||||
Telegram / VK link; and with `GATEWAY_MIN_CLIENT_VERSION` bumped above the build, an online action
|
||||
raises the terminal **update** overlay. Measure native chrome (safe-area / edge-to-edge) by CDP, not by
|
||||
eyeballing a screenshot — an emulator WebView may be newer than a user's device (see `.claude/CLAUDE.md`).
|
||||
|
||||
## Principles
|
||||
|
||||
|
||||
@@ -42,10 +42,15 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||
VITE_APP_VERSION=$VITE_APP_VERSION \
|
||||
VITE_ADS_STUB=$VITE_ADS_STUB
|
||||
|
||||
# Install with the lockfile first (the workspace file carries pnpm's build-script
|
||||
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
|
||||
# Install with the lockfile first, 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 ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY ui ./
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
@@ -221,22 +221,24 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
}
|
||||
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
Backend: backend,
|
||||
Limiter: limiter,
|
||||
Tracker: tracker,
|
||||
Banlist: banlist,
|
||||
Blocklist: blocklist,
|
||||
Honeytoken: cfg.Abuse.Honeytoken,
|
||||
VKAppSecret: cfg.VKAppSecret,
|
||||
Hub: hub,
|
||||
RateLimit: cfg.RateLimit,
|
||||
Heartbeat: cfg.PushHeartbeatInterval,
|
||||
Logger: logger,
|
||||
AdminProxy: adminProxy,
|
||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
Backend: backend,
|
||||
Limiter: limiter,
|
||||
Tracker: tracker,
|
||||
Banlist: banlist,
|
||||
Blocklist: blocklist,
|
||||
Honeytoken: cfg.Abuse.Honeytoken,
|
||||
VKAppSecret: cfg.VKAppSecret,
|
||||
Hub: hub,
|
||||
RateLimit: cfg.RateLimit,
|
||||
Heartbeat: cfg.PushHeartbeatInterval,
|
||||
Logger: logger,
|
||||
AdminProxy: adminProxy,
|
||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||
MinClientVersion: cfg.MinClientVersion,
|
||||
RecommendedClientVersion: cfg.RecommendedClientVersion,
|
||||
})
|
||||
|
||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"scrabble/gateway/internal/clientver"
|
||||
pkgtel "scrabble/pkg/telemetry"
|
||||
)
|
||||
|
||||
@@ -54,6 +55,16 @@ type Config struct {
|
||||
// MaxBodyBytes caps one inbound request body on the public listener and one
|
||||
// Connect message read; oversized requests are refused without buffering.
|
||||
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 RateLimitConfig
|
||||
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
||||
@@ -226,14 +237,16 @@ func (c VKIDConfig) Enabled() bool {
|
||||
func Load() (Config, error) {
|
||||
var err error
|
||||
c := Config{
|
||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
|
||||
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
|
||||
VKID: VKIDConfig{
|
||||
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
||||
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
||||
@@ -332,6 +345,23 @@ func (c Config) validate() error {
|
||||
if c.MaxBodyBytes <= 0 {
|
||||
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) {
|
||||
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
// the agreed thresholds, and no honeytoken.
|
||||
func TestLoadAbuseDefaults(t *testing.T) {
|
||||
|
||||
@@ -12,6 +12,7 @@ var (
|
||||
errInternal = errors.New("internal error")
|
||||
errMissingToken = errors.New("missing session token")
|
||||
errInvalidSession = errors.New("invalid or expired session")
|
||||
errUpdateRequired = errors.New("client too old, update required")
|
||||
)
|
||||
|
||||
// errUnknownMessageType reports an unregistered message type.
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"golang.org/x/net/http2/h2c"
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/clientver"
|
||||
"scrabble/gateway/internal/config"
|
||||
"scrabble/gateway/internal/push"
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
@@ -46,6 +47,21 @@ const heartbeatKind = "heartbeat"
|
||||
// spoofer, so trusting it is safe.
|
||||
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
|
||||
// class field of the periodic rejection report.
|
||||
const (
|
||||
@@ -88,6 +104,17 @@ type Server struct {
|
||||
|
||||
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
|
||||
userPolicy ratelimit.Policy
|
||||
emailPolicy ratelimit.Policy
|
||||
@@ -128,6 +155,13 @@ type Deps struct {
|
||||
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
||||
// zero or negative selects config.DefaultMaxBodyBytes.
|
||||
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.
|
||||
@@ -160,6 +194,30 @@ func NewServer(d Deps) *Server {
|
||||
if rl == (config.RateLimitConfig{}) {
|
||||
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{
|
||||
registry: d.Registry,
|
||||
sessions: d.Sessions,
|
||||
@@ -176,6 +234,10 @@ func NewServer(d Deps) *Server {
|
||||
adminProxy: d.AdminProxy,
|
||||
metrics: newServerMetrics(d.Meter, blocklist),
|
||||
maxBodyBytes: maxBody,
|
||||
minClient: minClient,
|
||||
gateOn: gateOn,
|
||||
recClient: recClient,
|
||||
recOn: recOn,
|
||||
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
||||
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
||||
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
|
||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||
// 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()
|
||||
msgType := req.Msg.GetMessageType()
|
||||
result := "internal"
|
||||
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)
|
||||
if !ok {
|
||||
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
|
||||
// heartbeat until the client disconnects.
|
||||
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()))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -22,8 +22,13 @@ import (
|
||||
)
|
||||
|
||||
// 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()) {
|
||||
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()
|
||||
backendSrv := httptest.NewServer(backendHandler)
|
||||
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)
|
||||
}
|
||||
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,
|
||||
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,
|
||||
})
|
||||
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())
|
||||
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) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("backend must not be called without a session")
|
||||
|
||||
@@ -31,6 +31,11 @@ enables the "Link Telegram" web sign-in (the Login Widget) — inert until the s
|
||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
|
||||
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
||||
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
|
||||
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
|
||||
in the RuStore MVP), `VITE_RUSTORE_URL` (the update overlay's store target; empty until published) and
|
||||
`VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by
|
||||
`.gitea/workflows/android-build.yaml`; see `ANDROID_PLAN.md`.
|
||||
|
||||
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {}
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
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>
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 913 B |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 754 B |
|
After Width: | Height: | Size: 333 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 564 B |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||