Compare commits

..

25 Commits

Author SHA1 Message Date
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
119 changed files with 60 additions and 5613 deletions
-211
View File
@@ -1,211 +0,0 @@
# Agent field notes — scrabble-game
Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`,
`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working
memory, not a spec — **verify any named file / flag / function against the current code before acting
on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up.
## Codegen & build
- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may
reorder output. After running it, revert the churn on tables you did not touch and commit only your
table's change.
- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different
flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with
another flatc version.
- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust
`go build` / `go test`, not the editor squiggles.
- **go-jet type mapping:** SQL `numeric``float64`, `interval``string`. Never store money as
`numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as
**int seconds**, not `interval`.
- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a
bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
- **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).
- **`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
delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs
and breaks older readers.
- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion,
and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value.
- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing
both the FBS schema and the backend DTO.
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
DTOs. Change both or they drift.
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
then bumps it from the live event. Don't expect it on the shared broadcast.
## UI / Svelte 5
- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store
subscription. Rename (e.g. `view`).
- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression:
`{' : '}`.
- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design
tokens (mirror `NewGame`'s `.invite`).
- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site
with `$state.snapshot(...)` before storing.
## Platform / WebView quirks (Telegram, VK, iOS, native, old Android)
- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a
`showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs.
- **iOS Telegram `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. Deliver a
client-generated file by **copying it to the clipboard**.
- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts`
(routes to `vk.com/away.php`). Verify on-device.
- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery
matrix; do not re-litigate it without new on-device facts.
- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers
syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot
gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin`
glyph fallback for old rendering.
- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify
those live on the deployed contour, not in the e2e.
- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived
Subscribe stream, cosmetic, deliberately left as-is.
## Testing
- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock
e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage.
- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it
intercepts Playwright taps.
- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to
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.
- **`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)
or the service crash-loops on start.
## Deploy / test contour (operational)
- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side
`curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way.
- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in
prod, not a bug — which is why the IP ban / blocklist are **prod-only**.
- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one
PR, one deploy) so deploys don't clobber each other.
- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a
prerelease step in `PRERELEASE.md`.
- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then
syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing
locale.
- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the
bot-link silently dies. Diagnose from inside the netns.
- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns
socket-inspect trick (single-service recreate).
- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for
caddy; don't trust "deploy green" for an edge-config change.
- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through
to the landing catch-all. Add a CI probe for the route.
- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request"
telemetry log or Tempo, not caddy.
- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside
`/assets/main-*.js`.
- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open;
clients cache it ~30 days. Fix with `Alt-Svc: clear`.
- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source
produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir.
- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans
the whole roll, the SPA overlay reloads on recovery. Don't break these invariants.
- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep
their pinned version. The **owner** does the upload.
- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes —
re-run, it's not your code.
## Repo workflow
- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it*
/ add a plan line — not file a tracker issue.
- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The
agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the
stale-mergeable trap (re-check mergeability right before merging).
- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched
**bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the
gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten
case — watch the post-merge runs too.
- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the
local feature branch.
- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a
wedged contour can be recovered by recreating the host container set.
## Domain semantics (not obvious from the code)
- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the
owner on every deletion point before wiring it.
- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email
account row is created at the **code-request** step, not at confirmation.
- **The robot has two distinct time windows:** a **sleep window** (~00:0007:00, gates its moves and
nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the
**sleep** window.
## Production topology
- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina).
SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling +
health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there —
the source of truth for IPs/roles).
- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every
owner-side step **before** a deploy, not after.
## Feature-area pointers (state lives in the linked docs/plans, not here)
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0E9). go-jet money rule above applies.
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
`deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
`--pg1-user=scrabble`.
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
offline-first bundles the dictionaries (see `ANDROID_PLAN.md`).
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
App, server-side confidential code exchange.
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
- **Native Android** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate,
offline-first, RuStore).
-680
View File
@@ -1,680 +0,0 @@
# Native Android (Capacitor) — RuStore MVP: bundle + client-version gate + offline-first
> Self-contained implementation plan for the standalone Android app, to be executed in a fresh
> session (another device). Work through the breakdown AG in order; each part states its own
> "Done when". `PLAN.md` at the repo root is the precedent for a top-level plan doc here.
---
## Context (why)
`erudit-game.ru` ships today as a web SPA (Svelte + Vite) served by the gateway, plus Telegram and
VK Mini Apps. The owner wants a **standalone Android app**, first on **RuStore**. The stack already
declares Capacitor as the target and is pre-seamed for it (feature-detected `window.Capacitor`,
`file://`-safe hash router, relative asset base, a reserved native share branch,
`viewport-fit=cover`/safe-area layout, a build-time gateway origin var), but **nothing is
scaffolded** — no `@capacitor/*` dep, no `capacitor.config.*`, no `android/`.
Two problems native introduces that the web never had:
1. **An installed build can be arbitrarily old.** On web every load fetches the current client, so
client and server are always paired. In a store a user may run a months-old bundle; if the
FlatBuffers wire schema changes incompatibly, an old bundle cannot speak to the server at all.
Today there is **no** client↔server version contract — the only "update" path is the web-only
`location.reload()`, useless to a bundled APK. → build a **minimum-supported-client gate**.
2. **First launch must work with no network.** The app must open straight into a guest experience
and let the user play locally (vs_ai *and* 2-4-player pass-and-play / hotseat) even if the
internet is off on first launch. The current offline mode is a *returning-user* feature: it needs
a cached session/profile and server-fetched dictionaries (`offline.ts shouldBootOffline` requires
`hasSession && hasProfile`; dicts come from `gateway.fetchDict`). → build **offline-first**:
bundle dictionaries in the APK and boot as a device-local guest with no server session.
**Intended outcome:** a signed APK on RuStore that (a) loads the packaged SPA against the production
gateway, (b) is protected by the version gate so future incompatible server changes turn old
installs away cleanly, and (c) opens offline-first as a soft guest with local play.
## Locked decisions (owner interview)
| Decision | Choice |
|---|---|
| First store | **RuStore** (Google Play is a later variant — see below) |
| Update model | **Bundle** (`dist/` inside the APK) **+ a client-version gate** (no OTA, no remote-URL wrapper) |
| First-launch offline | **Offline-first in the MVP**: enter as a local guest, play vs_ai + hotseat with zero network |
| Login surface | Guest + email only (already the case — `Login.svelte` shows only these; VK/Telegram auth runs only inside their Mini Apps) |
| Payments in MVP | Hidden (deferred; reuse the distribution flag) |
| appId (permanent) | **`ru.eruditgame.app`** |
| App display name | **`Эрудит`** (Cyrillic) |
| Toolchain (locked) | **Capacitor 8** (`@capacitor/*` `^8`); **compileSdk/targetSdk 36, minSdk 24**; **JDK 21** (required — `@capacitor/android` compiles at Java 21; AGP 8.13 / Gradle 8.14.3); Android SDK `platforms;android-36` + `build-tools;36.x` |
**Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code,
commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo
rejects standalone spec artifacts, so `ANDROID_PLAN.md` is the only new plan file; do not create
`docs/superpowers/specs/*`. Branch model: `feature/android-native` from `development`, PR into
`development` (contour review), then promote `development → master`, tag, dispatch the Android build.
The gate change is **wire-additive and contour-safe** (a new HTTP header + a new envelope
`result_code` string; no FBS/proto regen, no schema migration). The offline-first change is
client-only (no server change).
---
## Progress (as-built)
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
`ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build``cap sync``./gradlew assembleDebug`
builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator.
`svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in
`.claude/CLAUDE.md` → "Native Android build (Capacitor)".
**Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э»
brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive
icons + splashes. Superseded by the icon rebrand below.
- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts`
`gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL,
`screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left
as-is. Service worker skipped on the native channel (`lib/pwa.svelte.ts`). Payments hidden in the MVP
via new `purchasesHidden()` (`lib/distribution.ts`: folds `VITE_PAYMENTS_DISABLED` + the GP flag, plus
a `?nopay` mock force); the `Wallet.svelte` buy tab shows a neutral pointer-free note
(`wallet.purchasesSoon`) for the RuStore MVP, RuStore stub kept GP-only. Native env types in
`vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean;
`?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch
browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs).
- **C. Client-version gate — ✅ DONE & verified (2026-07-12).** Server: new `gateway/internal/clientver`
(parse + compare), `GATEWAY_MIN_CLIENT_VERSION` config (empty ⇒ dormant, validated at load), and the
gate in `connectsrv``Execute` returns `result_code="update_required"` before the registry lookup,
`Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client:
`X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store +
`UpdateOverlay.svelte` (native → `VITE_STORE_URL`, web → reload), `retry.ts` maps
`FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go
`clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl.
`update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app
store-update SDK noted Out of scope.
- **D. Offline-first — 🚧 IN PROGRESS (foundations done & committed `bcd5a1d`, 2026-07-12).** The
additive, web-inert groundwork landed; the boot rewrite + reconciliation + Profile soft-sign-in +
tests remain (see §D for the detailed remaining path and the session decisions). Done:
`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 (inert until the boot lands).
`svelte-check` 0, `vitest` 591, native + web builds clean.
- **EG — pending.**
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
the formats drift — `favicon.svg` is a rounded bordered tile, `icon-maskable-512.png` a full-bleed
square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable
(`capacitor-assets` insets it 16.7% into the adaptive safe zone). `~/.claude/bin/gitea-ci-watch.py` is present.
RuStore publication prerequisites gate release only.
---
## Prerequisites to start DEVELOPMENT (on the other device)
Have all of these before writing code:
- **JDK 21** (Temurin/OpenJDK) — **required** by Capacitor 8: `@capacitor/android` sets
`sourceCompatibility/targetCompatibility = VERSION_21`, so a JDK 17 Gradle run fails with
`invalid source release: 21`. (Homebrew: `brew install openjdk@21`.)
- **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or*
cmdline-tools. Capacitor 8 targets **compileSdk/targetSdk 36, minSdk 24**, so install:
`platform-tools`, `platforms;android-36`, `build-tools;36.0.0` (a newer build-tools such as
`36.1.0` is also accepted — AGP treats it as a minimum). Gradle 8.14.3 / AGP 8.13 arrive via the
wrapper `cap add android` generates.
- **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain.
- **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login
and the CI watcher exactly as the owner's global setup (`~/.claude` tooling:
`python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`).
- **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the
sibling `scrabble-solver` next to it — the solver **library** the backend/CI consume via the
`go.work` replace. **The bundled dictionaries do NOT come from the solver.** The versioned,
production dictionary set is published by `developer/scrabble-dictionary` as a **release artifact**
`scrabble-dawg-<DICT_VERSION>.tar.gz` (one semver label per set); `backend/Dockerfile` and every CI
job already `curl` exactly that tarball, keyed on the shared Gitea variable `DICT_VERSION`.
Offline-first bundles the DAWGs from that same release (see D.1 / E), so the Android build needs
`DICT_VERSION` + network access to the release, **not** `scrabble-solver`. (`scrabble-solver/dawg/*.dawg`
are the solver's committed test fixtures — byte-identical to a build but pinned to the solver
commit, not the versioned production set.)
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. a
Pixel AVD; any recent API image).
- **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway
is only needed to exercise the version gate locally (optional).
Publication prerequisites (RuStore account, keystore, listing assets) are listed at the **end**
they gate *release*, not development.
---
## Identity model (answers "how do guests work offline vs in the DB")
Split the **local play identity** from the **server account**. This *extends* the current model
(guests are DB rows) with a pre-server local state; existing server-guest semantics are unchanged.
- **Local guest (device-local, no DB row).** A device-generated id + a default display name,
persisted on the device. Exists from the very first launch, with no network. It fills the human
seat in a local vs_ai game and is the "you" for local games. A purely-offline user never creates a
DB row (consumes no server resources).
- **Server guest (DB row).** Minted lazily via `auth.guest` the first time the app reaches the
network, its session cached and reused. Unlocks online features (matchmaking, friends, online
games). Exactly one per device — guarded by the cached session (never mint if one exists).
- **Registered account.** Email upgrade as today; adopts the current server guest.
- **Local games stay device-only.** Both local vs_ai and local hotseat games already persist only on
the device (`ui/src/lib/localgame/store.ts`); they never sync to the server, regardless of identity
transitions. Reconciliation to a server guest does **not** migrate them.
**Reconciliation flow:** on gaining network with no server session, silently `auth.guest` in the
background, cache the session, adopt it. This is best-effort — see the gate interaction below for how
a too-old client stays offline instead of being interrupted.
---
## The client-version gate — architecture (read before touching gate code)
**Principle: the version rides the outermost, permanently-stable layer, never the FlatBuffers
payload.** The transport is two layers: a protobuf Connect envelope
(`ExecuteRequest{message_type, payload, request_id}`, `gateway/proto/edge/v1/edge.proto`) wrapping a
FlatBuffers payload (`pkg/fbs/scrabble.fbs`). Protobuf envelopes and HTTP headers are
version-tolerant by design; the FBS payload is the layer that breaks. So the client version rides in
an **HTTP header `X-Client-Version`**, read by the gateway **before it decodes the payload**.
**Enforcement is per-call, not a dedicated init RPC.** The client attaches the header to every
Connect call via its single `headers()` builder; the gateway checks it at the top of `Execute`
(before registry lookup / auth / payload decode) and `Subscribe`. The first online call the app makes
(session establish `auth.*`) is thus gated automatically — no `Hello` RPC needed. A too-old client
makes **zero** successful requests but sees a recognizable signal, not a crash.
**Two return shapes, one meaning:**
- `Execute` → the domain-style envelope `result_code = "update_required"` (HTTP 200); the client
already throws `GatewayError(result_code)` for any non-`ok`.
- `Subscribe` → Connect `CodeFailedPrecondition` (a stream has no `result_code`).
**Frozen contract (write into `docs/ARCHITECTURE.md` §2):** three things become permanent so any
build, however old, can always recognize "update required": (1) the protobuf envelope field numbers
in `edge.proto` are never renumbered/reused; (2) the `update_required` sentinel (the `result_code`
string + the `FailedPrecondition` code) never changes; (3) the FBS schema stays **additive**
(trailing fields only, `(deprecated)` never delete). Breaking changes happen only *inside* the FBS
payload.
**Discipline (write into `deploy/README.md`):** the production deploy that ships an incompatible wire
change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same rollout. Until
deliberately set, `GATEWAY_MIN_CLIENT_VERSION` is **empty ⇒ the gate is dormant and web behaviour is
unchanged.** One hard threshold for the MVP; a soft "recommended update" tier is deferred.
**Interaction with offline-first (important UX rule):** offline mode uses the network kill switch
(`transport.ts assertOnline`), so the gate never fires while playing offline — an old client can
always play local vs_ai/hotseat. The gate matters only for online actions. Therefore the terminal
"update" overlay must be raised **only on a user-initiated online action**, never on the silent
background guest-reconciliation: if reconciliation's `auth.guest` returns `update_required`, swallow
it and stay a local guest (do not overlay). Implementation seam: the reconciliation path catches the
`update_required` code and does not route it to the global overlay trigger; foreground calls do.
---
## Work breakdown
Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence
is: scaffold → native-correct → gate → offline-first → CI → docs → release.
### A. Capacitor scaffolding — ✅ DONE
- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps
`@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`,
`"android:assets": "capacitor-assets generate --android"`.
- `ui/capacitor.config.ts` (new):
```ts
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'ru.eruditgame.app',
appName: 'Эрудит',
webDir: 'dist',
// Bundle model: no server.url — the WebView loads the packaged dist/ from app assets. Updates
// ship through the store; the client-version gate turns away a build too old to speak the
// current wire contract (docs/ARCHITECTURE.md §2).
};
export default config;
```
- `npx cap add android` → generates and **commits** `ui/android/` (a Gradle project). Neither
`.gitignore` lists it today; keep it tracked so build.gradle + signing edits are reviewable and CI
reproducible. Ensure `ui/android/.gitignore` covers build outputs (`/app/build`, `/build`,
`/.gradle`, `/local.properties`, `*.keystore`, `*.jks`).
- **Android hardware Back** — new `ui/src/lib/native.ts`, dynamically importing `@capacitor/app` so
the web/mock bundle never pulls it:
```ts
import { clientChannel } from './channel';
export async function initNativeShell(atNavigationRoot: () => boolean): Promise<void> {
const ch = clientChannel();
if (ch !== 'android' && ch !== 'ios') return;
const { App } = await import('@capacitor/app');
App.addListener('backButton', () => {
if (atNavigationRoot()) void App.exitApp();
else history.back();
});
}
```
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
(mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`).
- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png`
(+ optional `splash.png`); `pnpm android:assets` generates adaptive icons.
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
### B. Native web-build correctness (file:// origin) — ✅ DONE
1. **One origin helper** — new `ui/src/lib/origin.ts`:
```ts
// The absolute origin the client talks to. A packaged native build (file:// origin) sets
// VITE_GATEWAY_URL to the real gateway; web/mock leave it empty and fall back to the page
// origin. Centralised so every absolute-URL construction resolves identically.
export function gatewayOrigin(): string {
const configured = import.meta.env.VITE_GATEWAY_URL ?? '';
return configured || (typeof location !== 'undefined' ? location.origin : '');
}
```
Fix the two same-origin **landmines** (they would produce `file:///…` on native):
- `ui/src/game/Game.svelte:1214`: `new URL(path, location.origin)` → `new URL(path, gatewayOrigin())`.
- `ui/src/screens/Wallet.svelte:47`: `` `${location.origin}/` `` → `` `${gatewayOrigin()}/` ``.
(`transport.ts:32` already computes the equivalent inline — leave it.)
2. **Skip the service worker on native** — `ui/src/lib/pwa.svelte.ts registerServiceWorker()` (line 85):
add, next to `if (inMiniApp()) return;`:
```ts
import { clientChannel } from './channel';
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') return;
```
3. **Hide payments in the MVP build** — `ui/src/lib/distribution.ts`, add:
```ts
// purchasesHidden: true for the Google Play build (external-payment policy) OR the thin native
// MVP that defers store billing (VITE_PAYMENTS_DISABLED="1"). Every other build sells normally.
export function purchasesHidden(): boolean {
return isGooglePlayBuild() || (import.meta.env as Record<string, string | undefined>).VITE_PAYMENTS_DISABLED === '1';
}
```
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub
`isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() &&
!isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key
`wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the
same note can replace the RuStore stub in the later Google Play anti-steering variant.
`purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
4. **Native env matrix** — see Build & env.
**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
### C. The client-version gate — ✅ DONE
#### C1. Backend (gateway)
- **New package `gateway/internal/clientver`** (`clientver.go` + `_test.go`): dependency-free parse of
the leading `v?MAJOR.MINOR.PATCH` (ignore any `-N-gSHA`/`+meta` suffix) + compare:
```go
package clientver
import ("strconv"; "strings")
type Version struct{ Major, Minor, Patch int }
func Parse(s string) (Version, bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
if i := strings.IndexAny(s, "-+"); i >= 0 { s = s[:i] }
p := strings.SplitN(s, ".", 4)
if len(p) < 3 { return Version{}, false }
var v Version; var err error
if v.Major, err = strconv.Atoi(p[0]); err != nil { return Version{}, false }
if v.Minor, err = strconv.Atoi(p[1]); err != nil { return Version{}, false }
if v.Patch, err = strconv.Atoi(p[2]); err != nil { return Version{}, false }
return v, true
}
func Less(a, b Version) bool {
if a.Major != b.Major { return a.Major < b.Major }
if a.Minor != b.Minor { return a.Minor < b.Minor }
return a.Patch < b.Patch
}
```
Tests: `"v1.16.0"`, `"1.16.0"`, `"v1.16.0-3-gabc"`, `"dev"`/`""`→!ok, ordering incl. equal.
- **Config `gateway/internal/config/config.go`**: add `MinClientVersion string` (doc: empty ⇒ gate
off); `Load()`: `c.MinClientVersion = os.Getenv("GATEWAY_MIN_CLIENT_VERSION")`; `validate()`: if
non-empty and `!clientver.Parse(...ok)` → return a config error. Extend `config_test.go`.
- **Server `gateway/internal/connectsrv/server.go`**:
- consts `clientVersionHeader = "X-Client-Version"`, `resultUpdateRequired = "update_required"`;
`errors.go`: `errUpdateRequired = errors.New("client too old, update required")`.
- `Server`: add `minClient clientver.Version` + `gateOn bool`. `Deps`: add `MinClientVersion string`.
`NewServer`: parse `d.MinClientVersion` once (set `gateOn` on success; `log.Warn` + off if
unparseable).
- helper `clientTooOld(header string) bool`: `if !s.gateOn { return false }`; parse header, on
`!ok` return false (fail-open); return `clientver.Less(v, s.minClient)`.
- `Execute`: insert **after** the `defer` metrics line (after `server.go:296`, before
`registry.Lookup`), so `msgType` is set and the payload is untouched:
```go
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
result = resultUpdateRequired
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(), ResultCode: resultUpdateRequired,
}), nil
}
```
- `Subscribe`: at the top (before `resolve`): `if s.clientTooOld(req.Header().Get(clientVersionHeader)) { return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired) }`.
- `main.go`: add `MinClientVersion: cfg.MinClientVersion,` to the `connectsrv.Deps{...}` literal.
- Tests (`server_test.go`): too-old Execute ⇒ `result_code == "update_required"` and the op handler
never ran; too-old Subscribe ⇒ `FailedPrecondition`; absent header / empty min / unparseable
header / equal version ⇒ pass.
#### C2. Client (ui)
- **`ui/src/lib/update.svelte.ts`** (new, terminal — no poll):
```ts
export const UPDATE_REQUIRED = 'update_required';
let required = $state(false);
export const updateRequired = { get active(): boolean { return required; } };
export function reportUpdateRequired(): void { required = true; }
```
- **`ui/src/lib/transport.ts`**:
- `headers()` (line 37) always attaches the version header:
```ts
const headers = (): Record<string, string> => {
const h: Record<string, string> = { 'x-client-version': __APP_VERSION__ };
if (token) h.authorization = `Bearer ${token}`;
return h;
};
```
- result-code branch (line 86): `if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired();`
before the throw.
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
- **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant
(swallows `update_required` without raising the overlay) has no caller until D.4, so it is added
there with its caller rather than speculatively in C. In C every foreground call that gets
`update_required` raises the overlay; offline play never trips it (the network kill switch).
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys
`update.title/body/action` (add siblings to the maintenance keys).
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
- **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via
Playwright `update.spec.ts` (`__update.on()`). The `update.svelte.ts` store is a `$state` rune module,
which this project's plugin-less `vitest` cannot import (`$state is not defined`); like every other
`*.svelte.ts` store it is covered by the e2e, not a unit test.
**Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local
gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) — 🚧 IN PROGRESS
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**.
**Status:** the additive foundations (D.1, D.2) are **done & committed (`bcd5a1d`)** and inert on the
web; the boot rewrite (D.3), reconciliation + the silent seam (D.4), the Profile soft-sign-in (D.6) and
the tests remain. **Verify every line ref below against current code** (they are as of 2026-07-12).
**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 — ▢ TODO** (`ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`). **High
blast-radius: this is app startup for every platform — gate every change on the native channel and
leave the web / PWA / TG / VK paths byte-for-byte.**
- The blocking Login is **`bootstrap()` app.svelte.ts ~line 989-991**:
`} else if (router.route.name !== 'login' && router.route.name !== 'confirm') { navigate('/login'); }`
— the `else` of `if (saved)` (no cached session). **On native, replace this branch** with the
offline-first entry: establish the local guest (`localGuestId()`), `setOfflineMode(true, false)`
(**non-sticky** auto-offline, so reconciliation may clear it — cf. the sticky arg at ~line 968), and
land in the **lobby** (leave the route / `navigate('/')`), `app.ready = true`. Never navigate to
`/login` on native.
- The native no-session boot **always lands in the lobby**, online or offline. If reachable at boot,
also kick reconciliation (D.4); if not, stay a local guest until the network returns. The lobby
renders without a session — offline mode shows local games and `NewGame` gates its offline flows on
`guest || offlineMode.active`.
- `shouldBootOffline` (offline.ts:85, currently `offlineActive && hasSession && hasProfile`): add a
**native-no-session** path so a native cold start can boot offline as the local guest with no cached
session/profile. Keep the web rule intact.
- `offlinePreloadEligible` (offline.ts:69): **bypass the server preload on native** — the dicts are
bundled, so there is nothing to preload; keep the web `standalone && hasEmail` gate.
4. **Lazy server-guest reconciliation + the silent seam — ▢ TODO** (the seam was deferred from C).
- **Silent seam:** the C transport calls `reportUpdateRequired()` in three places — `exec` result-code
branch (`transport.ts ~line 86`), `exec` catch (`~line 73`), `subscribe` catch (`~line 366`). Add a
**silent path** that does NOT raise the overlay: e.g. an `exec(msgType, payload, signal, opts?: {
silent?: boolean })` flag, plus an `authGuestSilent(locale)` on the `GatewayClient` interface
(`lib/client.ts`), the real transport, **and the mock** (`lib/mock/client.ts` — the e2e reconciliation
leg goes through the mock). Foreground `auth.*` keep the overlay.
- **Reconciliation:** when native + online + **no** server session → background `authGuestSilent`,
`saveSession` + adopt (`setToken` + `app.session`/`app.profile`, or reuse `adoptSession`), then
**clear the auto-offline** (`setOfflineMode(false)`) so online features + Profile light up. Guard
duplicates (only when no cached session — never mint a second server guest). On `update_required` it
stays offline silently (no overlay). Local games stay device-only. Fire it on boot (if reachable) and
on network-recovery (a connection-online handler).
5. **Both offline modes render for the local guest — ✅ mostly (verify at D close).** `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).
6. **Soft registration = reuse the `Profile` screen — ▢ TODO.**
- Profile is already reachable for an **online** guest (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 — **no change is needed to reach Profile**, provided
D.4 clears the auto-offline when online.
- **Hide tg/vk on native:** `Profile.svelte` — the Telegram link button (`~line 485`, gated on
`telegramLinkable = loginWidgetAvailable()`, `:62`) and the VK link button (`~line 499`). Add a
`clientChannel()` android/ios check to hide both on the native build; keep email + account management.
(See the decision block above for why.)
**Tests — ▢ TODO** (docs/TESTING.md layers; the mock e2e bypasses the codec, so the gate's server path
stays in the Go tests):
- **Unit (vitest, node):** the bundled-dict tier — a `fetch` mock returning a dawg blob **with the native
channel forced** (`vi.stubGlobal('window', { Capacitor: { getPlatform: () => 'android' } })`, since the
tier is native-gated; `idbPutDawg`/`requestPersist` are best-effort `void`); `shouldBootOffline`
native-no-session; `localguest` (`localGuestId` persistence + `isLocalGuestId`). (`update.svelte.ts`-style
reminder: a `$state` rune module can't be imported under this project's plugin-less vitest.)
- **Playwright offline-first spec:** **simulate native** by injecting `window.Capacitor = { getPlatform:
() => 'android' }` via `addInitScript` (makes `clientChannel()` return `android`, activating the bundled
tier + the native boot). Place bundled dicts at **`dist-e2e/dict/<variant>@dev.dawg`** — extend the
`playwright.config.ts` webServer command to also run `DICT_DIR=$E2E_DICT_DIR OUT_DIR=dist-e2e node
scripts/bundle-dicts.mjs` (`VITE_DICT_VERSION` unset → `dev`, matching `__DICT_VERSION__`). Boot with the
network hook off (`window.__conn.offline()` / the offline pref) → assert the **lobby** (not login), play a
local vs_ai move, start a hotseat game; then network on → reconciliation lights up.
**GOTCHA:** with `window.Capacitor` injected, `initNativeShell` (`App.svelte:40`) will `await
import('@capacitor/app')` and call `App.addListener('backButton', …)` — there is no real Capacitor
bridge in the browser, so guard/stub it (inject a fake `Capacitor.Plugins.App`, or make `initNativeShell`
tolerate a missing bridge) or the boot may throw.
**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 (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
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
1. Checkout this repo; copy the Node/pnpm setup block from `.gitea/workflows/ci.yaml` (the `ui` job).
Add the **"Fetch dictionary DAWGs"** step verbatim from `ci.yaml` (the `curl …
scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz` +
untar into `${GITHUB_WORKSPACE}/dawg`), keyed on the `DICT_VERSION` Gitea variable — **this**, not
the `scrabble-solver` sibling, is the source of the bundled dicts. (`scrabble-solver` is not needed
by the Android build: `ui` is a Node project outside `go.work`.)
2. `pnpm install --frozen-lockfile`.
3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`,
`VITE_DICT_VERSION` = the release dict version.
4. `DICT_DIR=${GITHUB_WORKSPACE}/dawg node ui/scripts/bundle-dicts.mjs` (copy the release `.dawg`
files into `ui/dist/dict/`).
5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21).
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`,
`build-tools;36.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
7. `npx cap sync android`.
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
tag (scheme below).
9. `cd ui/android && ./gradlew assembleRelease -PversionCode=$CODE -PversionName=$NAME` with the
signing config reading the keystore + `ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/
`ANDROID_KEY_PASSWORD` from env.
10. Upload `ui/android/app/build/outputs/apk/release/*.apk` as an artifact.
Watch to green with `python3 ~/.claude/bin/gitea-ci-watch.py` (background). RuStore upload is manual
for the MVP.
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
`versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`.
Wire in `ui/android/app/build.gradle` `defaultConfig`:
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`,
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release`
reading env/props (guarded so a local `assembleDebug` still builds unsigned).
### F. Docs (bake in the same PR)
- `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** +
the gate×offline rule. New sections — the **identity model** (local guest / server guest /
reconciliation) and the **native Android build** (Capacitor bundle model, bundled dicts,
`versionCode` scheme, RuStore, `file://`-origin implications).
- `docs/FUNCTIONAL.md` (+ `docs/FUNCTIONAL_ru.md` mirror): user stories — offline-first guest launch
(vs_ai + hotseat with no network), soft registration, "update required when too old", native
distribution (no purchases in the MVP).
- `docs/TESTING.md`: the new Go tests (`clientver`, gate), the client tests, the bundled-dict tier
test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to
guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway;
network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped).
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`,
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value.
- `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
ships an incompatible wire change.
### G. Release + owner handoff
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
2. Promote `development` → `master`; tag `vX.Y.0`.
3. Dispatch `android-build`; watch green; retrieve the signed APK.
4. Owner runs the on-device smoke checklist (incl. airplane-mode first launch).
5. Owner uploads to RuStore (see publication prerequisites).
---
## Google Play variant (later — design now so we don't repaint)
One codebase, two build variants selected by flags:
- **RuStore build** (this MVP): `VITE_PAYMENTS_DISABLED=1` (MVP), `VITE_GP_BUILD` unset. Later, when
RuStore billing lands, RuStore sells normally.
- **Google Play build** (later): `VITE_GP_BUILD=1` ⇒ `purchasesHidden()` true. The **only** functional
difference is that purchases are hidden — the flag structure (`purchasesHidden()`) already carries
both cases, so no repaint is needed.
**Google Play compliance — decided: no RuStore steering.** Google forbids not just external billing
but *steering* users to other payment methods or app stores. So the Google Play build hides purchases
with **no** pointer to RuStore: the existing `isGooglePlayBuild()` RuStore-pointer stub is **dropped**
in the GP variant (show nothing, or a neutral pointer-free message — never name or link RuStore or any
other store). This does not affect the RuStore MVP (there `isGooglePlayBuild()` is false, so the stub
never shows). Google Play also requires an **AAB** (not APK) and Play App Signing — a separate CI
target, built later.
---
## Build & env matrix
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var).
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target.
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header.
- `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests.
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
**Web/contour/prod builds:** unchanged. `VITE_GATEWAY_URL` empty (same-origin),
`GATEWAY_MIN_CLIENT_VERSION` unset (gate dormant). Nothing about web behaviour changes until a future
breaking release deliberately sets the min version.
---
## Prerequisites to start PUBLICATION (RuStore)
- **RuStore developer account** — a registered legal entity or self-employed (самозанятый) RU status,
verified. Gates publication (not the build).
- **Release keystore** — generate once and **back up immutably** (losing it means never updating this
app again):
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`.
Set Gitea repo secrets: `ANDROID_KEYSTORE_BASE64` (base64 of the .jks),
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
- **The signed APK** — from the `android-build` artifact.
- **Store listing** — reserve `ru.eruditgame.app`; title «Эрудит»; RU description; phone screenshots;
icon/feature graphic; category; age rating; a hosted **privacy-policy URL**; the RuStore content
declaration.
- (For the later **Google Play** variant: a Google Play Console account ($25), the GP build (payments
hidden + the anti-steering fix), an **AAB**, Play App Signing, and the Play Data-safety form.)
## Risks & mitigations
- **Keystore loss** → app can never be updated. Back up off-host immediately.
- **Gate dormant until first bump** → its real firing only happens on a future breaking release. The
Go `server_test.go` tests + the Playwright `__update` e2e prove the whole path now.
- **Update overlay interrupting offline play** → the gate×offline rule (overlay only on user-initiated
online actions; silent reconciliation swallows `update_required`).
- **`X-Client-Version` stripped by the edge** → Caddy forwards request headers by default (the RPC
route already reaches the gateway); no Caddyfile change. Confirm via the smoke (bump min → overlay).
- **APK size from bundled dicts** → a few MB per variant; acceptable. Could trim to RU-only later.
- **RuStore account gate** (legal status) → surface to the owner before release.
## Out of scope (deferred)
Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app
purchases in the native build (RuStore billing SDK) and the GP anti-steering fix, VK/Telegram login in
the native build, a soft "recommended update" tier, **in-app store-update flows** (Google Play In-App
Updates API / RuStore In-App Update SDK — a future enhancement that would swap the update overlay's
action from opening the store listing to a store-driven immediate in-app update; needs a Capacitor
plugin bridge, and is orthogonal to the server-driven gate), native splash/status-bar plugins, push
notifications (FCM), and automated RuStore-API upload.
## End-to-end verification
- **Unit/integration:** `go test ./gateway/...` (clientver + gate), `cd ui && pnpm check &&
pnpm test:unit && pnpm test:e2e`, `gofmt -l .` clean, `go vet ./gateway/...`.
- **Gate proof:** gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` ⇒ overlay; unset ⇒ normal.
- **Offline-first proof:** native `assembleDebug`, install, **airplane mode**, cold launch → guest
lobby → play a local vs_ai move and a 2-player hotseat game; network on → server guest established.
- **Native build proof:** guest sign-in online, a move, Back navigates then exits at root, a
share/export link points at `erudit-game.ru`, purchases absent.
- **Signed pipeline:** dispatch `android-build`; watcher green; the release APK installs and launches.
- **Contour:** the `feature/android-native` PR deploys cleanly (no schema/wire regen; gate dormant);
web/VK/TG behaviour unchanged.
-8
View File
@@ -156,11 +156,3 @@ The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` j
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
## Agent field notes
Non-obvious, hard-won knowledge the agent has accumulated that is **not** captured in the docs above,
kept in the repo so it travels with a clone. Verify any named file/flag against current code before
acting on it.
@.claude/CLAUDE.md
+16 -17
View File
@@ -221,23 +221,22 @@ 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,
MinClientVersion: cfg.MinClientVersion,
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,
})
// Bridge the backend push stream into the fan-out hub (and the out-of-app
-57
View File
@@ -1,57 +0,0 @@
// 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
}
@@ -1,63 +0,0 @@
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)
}
})
}
}
+8 -20
View File
@@ -9,7 +9,6 @@ import (
"strings"
"time"
"scrabble/gateway/internal/clientver"
pkgtel "scrabble/pkg/telemetry"
)
@@ -55,11 +54,6 @@ 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
// RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
@@ -232,15 +226,14 @@ 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"),
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
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"),
VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
@@ -339,11 +332,6 @@ 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.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")
}
-23
View File
@@ -47,29 +47,6 @@ 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")
}
}
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
// the agreed thresholds, and no honeytoken.
func TestLoadAbuseDefaults(t *testing.T) {
-1
View File
@@ -12,7 +12,6 @@ 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.
-66
View File
@@ -26,7 +26,6 @@ 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"
@@ -47,16 +46,6 @@ 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". Both are part of the frozen wire contract (docs/ARCHITECTURE.md §2).
const (
clientVersionHeader = "X-Client-Version"
resultUpdateRequired = "update_required"
)
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
// class field of the periodic rejection report.
const (
@@ -99,11 +88,6 @@ 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
publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy
@@ -144,10 +128,6 @@ 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
}
// NewServer constructs the edge service.
@@ -180,18 +160,6 @@ 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))
}
}
return &Server{
registry: d.Registry,
sessions: d.Sessions,
@@ -208,8 +176,6 @@ func NewServer(d Deps) *Server {
adminProxy: d.AdminProxy,
metrics: newServerMetrics(d.Meter, blocklist),
maxBodyBytes: maxBody,
minClient: minClient,
gateOn: gateOn,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
@@ -319,22 +285,6 @@ 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)
}
// 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.
@@ -344,17 +294,6 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
result := "internal"
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
// 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"
@@ -424,11 +363,6 @@ 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
+7 -91
View File
@@ -22,13 +22,8 @@ import (
)
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
// client plus a cleanup func. The client-version gate is off.
// client plus a cleanup func.
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
return newEdgeMin(t, "", backendHandler)
}
// newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off).
func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
@@ -36,13 +31,12 @@ func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc
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,
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,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
@@ -114,84 +108,6 @@ 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)
}
})
}
}
// 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")
-101
View File
@@ -1,101 +0,0 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Release signing material must never be committed (see ANDROID_PLAN.md — keystore loss/leak risk).
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
-2
View File
@@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep
-54
View File
@@ -1,54 +0,0 @@
apply plugin: 'com.android.application'
android {
namespace = "ru.eruditgame.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "ru.eruditgame.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
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:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
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")
}
-19
View File
@@ -1,19 +0,0 @@
// 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')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
-21
View File
@@ -1,21 +0,0 @@
# 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
@@ -1,26 +0,0 @@
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());
}
}
@@ -1,41 +0,0 @@
<?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>
@@ -1,5 +0,0 @@
package ru.eruditgame.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

@@ -1,34 +0,0 @@
<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>
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

@@ -1,12 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

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

Before

Width:  |  Height:  |  Size: 46 KiB

-12
View File
@@ -1,12 +0,0 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'ru.eruditgame.app',
appName: 'Эрудит',
webDir: 'dist',
// Bundle model: no server.url — the WebView loads the packaged dist/ from app assets. Updates
// ship through the store; the client-version gate turns away a build too old to speak the
// current wire contract (docs/ARCHITECTURE.md §2).
};
export default config;
-15
View File
@@ -38,20 +38,7 @@ async function openFinishedHistory(page: Page): Promise<void> {
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
}
// Desktop WebKit (Safari's engine) exposes a working Web Share API, unlike Chromium and WebKit on
// Linux (the CI host). The "plain desktop browser" delivery paths tested below are reached only when
// Web Share is ABSENT — shareUrlAsFile / pickGcgDelivery fall through to the anchor download or the
// clipboard copy — so pin that precondition; otherwise macOS WebKit takes the share branch and no
// download or clipboard copy occurs. Mirrors the inverse stub the share-sheet test sets up.
async function withoutWebShare(page: Page): Promise<void> {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'share', { value: undefined, configurable: true });
Object.defineProperty(navigator, 'canShare', { value: undefined, configurable: true });
});
}
test('the chooser downloads the PNG through the signed URL on a plain browser', async ({ page }) => {
await withoutWebShare(page);
await routeDl(page);
await openFinishedHistory(page);
await page.getByRole('button', { name: 'Export game' }).click();
@@ -62,7 +49,6 @@ test('the chooser downloads the PNG through the signed URL on a plain browser',
});
test('the chooser downloads the GCG through the same signed-URL route', async ({ page }) => {
await withoutWebShare(page);
await routeDl(page);
await openFinishedHistory(page);
await page.getByRole('button', { name: 'Export game' }).click();
@@ -212,7 +198,6 @@ test('Telegram on iOS keeps the app chooser and opens the OS share sheet', async
});
test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => {
await withoutWebShare(page);
await page.addInitScript(() => {
// Headless engines deny the real clipboard; a permissive stub keeps the legacy
// copy path deterministic in both browsers.
-34
View File
@@ -1,34 +0,0 @@
import { expect, test } from './fixtures';
// The "update required" overlay is raised in prod when the edge's client-version gate turns away a
// too-old build (the update_required result_code on Execute, a Subscribe FailedPrecondition). The
// mock transport never produces one, so — like the maintenance overlay — the e2e drives it through
// the window.__update hook (gateway.ts, mock-only). It is app-global (mounted outside the route
// blocks in App.svelte), so it shows without a session, and it is terminal (no self-clearing poll).
test('update overlay covers the app when the client is too old', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const overlay = page.getByRole('alertdialog');
await expect(overlay).toBeVisible();
// One action button (EN "Update" / RU "Обновить" depending on locale).
await expect(overlay.getByRole('button', { name: /Update|Обновить/i })).toBeVisible();
});
test('the update action reloads the web client', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const overlay = page.getByRole('alertdialog');
await expect(overlay).toBeVisible();
// On the web the action reloads the page to fetch the current client (on a native build it opens
// the store instead). The reload resets the terminal store, so the app re-bootstraps: the overlay
// is gone and the login is back.
await Promise.all([
page.waitForEvent('load'),
overlay.getByRole('button', { name: /Update|Обновить/i }).click(),
]);
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
});
-16
View File
@@ -105,22 +105,6 @@ test('wallet: the Google Play build hides the money purchases behind the RuStore
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
});
test('wallet: the native MVP hides the money purchases behind a neutral note (no store pointer)', async ({ page }) => {
await page.goto('/?nopay');
await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Your turn')).toBeVisible();
await openWallet(page);
// Buy chips: the chip pack is gone; a neutral note shows and — unlike the Google Play build — it
// carries no RuStore (or any store) pointer.
await expect(page.getByTestId('purchases-hidden')).toBeVisible();
await expect(page.getByTestId('gp-stub')).toHaveCount(0);
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
// Spending earned chips still works — the values live in the Spend tab.
await page.getByTestId('tab-spend').click();
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
});
test('wallet: a web spend that would draw store chips confirms with a warning, then completes', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
-7
View File
@@ -9,8 +9,6 @@
"start": "vite --mode mock",
"build": "vite build",
"preview": "vite preview",
"cap:sync": "cap sync android",
"android:assets": "capacitor-assets generate --android",
"check": "svelte-check --tsconfig ./tsconfig.json",
"codegen": "rm -rf src/gen && flatc --ts -o src/gen/fbs ../pkg/fbs/scrabble.fbs && buf generate ../gateway --template buf.gen.yaml",
"test:unit": "vitest run",
@@ -19,9 +17,6 @@
},
"dependencies": {
"@bufbuild/protobuf": "^2.12.0",
"@capacitor/android": "^8.4.1",
"@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.4.1",
"@connectrpc/connect": "^2.1.0",
"@connectrpc/connect-web": "^2.1.0",
"@vkontakte/vk-bridge": "^3.0.2",
@@ -29,8 +24,6 @@
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.12.0",
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.4.1",
"@playwright/test": "^1.49.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0",
-2856
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -6,7 +6,3 @@
allowBuilds:
core-js-bundle: false
esbuild: true
# sharp (via @capacitor/assets) rasterises the launcher icon/splash for the
# `android:assets` script. It installs a prebuilt binary via prebuild-install —
# no native toolchain needed — so allow its install script to materialise it.
sharp: true
-42
View File
@@ -1,42 +0,0 @@
// Copies the production dictionary DAWGs into the build's app assets so a native (offline-first)
// install can obtain a dictionary with no network: the loader's bundled tier fetches
// ./dict/<dictKey>.dawg, where dictKey is "<variant>@<version>" (see lib/dict/store.ts). Run only in
// the native pipeline (after `pnpm build`, before `cap sync`); web builds skip it and stay slim.
//
// Source: DICT_DIR — the unpacked scrabble-dictionary release (scrabble-dawg-<DICT_VERSION>.tar.gz),
// the SAME set the backend image and CI consume, NOT the solver's committed test fixtures. The bundled
// version label comes from VITE_DICT_VERSION (default "dev") and MUST equal the client's __DICT_VERSION__
// so the loader requests the exact (variant, version) the file is named for. OUT_DIR overrides the
// output root (default `dist`) — the e2e build points it at dist-e2e.
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';
const srcDir = process.env.DICT_DIR;
if (!srcDir) {
console.error('bundle-dicts: DICT_DIR is required (the unpacked scrabble-dictionary release dir)');
process.exit(1);
}
const version = process.env.VITE_DICT_VERSION || 'dev';
const outDir = join(process.env.OUT_DIR || 'dist', 'dict');
// The app's Variant enum value -> the release dawg file name (matches e2e-dict.mjs and the movegen
// parity mapping).
const dawgFor = {
scrabble_en: 'en_sowpods',
scrabble_ru: 'ru_scrabble',
erudit_ru: 'ru_erudit',
};
mkdirSync(outDir, { recursive: true });
let copied = 0;
for (const [variant, file] of Object.entries(dawgFor)) {
const src = join(srcDir, `${file}.dawg`);
if (!existsSync(src)) {
console.warn(`bundle-dicts: missing ${src} — the bundled tier will 404 for ${variant} (set DICT_DIR)`);
continue;
}
copyFileSync(src, join(outDir, `${variant}@${version}.dawg`));
copied++;
}
console.log(`bundle-dicts: copied ${copied}/${Object.keys(dawgFor).length} dawgs -> ${outDir} (version ${version})`);
-7
View File
@@ -4,14 +4,12 @@
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
import { router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
import { initNativeShell } from './lib/native';
import Toast from './components/Toast.svelte';
import Splash from './components/Splash.svelte';
import StaleInviteModal from './components/StaleInviteModal.svelte';
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
import Coachmark from './components/Coachmark.svelte';
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
import UpdateOverlay from './components/UpdateOverlay.svelte';
import DebugPanel from './components/DebugPanel.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
@@ -34,10 +32,6 @@
void bootstrap().then(() => {
(window as unknown as { __booted?: boolean }).__booted = true;
});
// Native shell only: wire the Android hardware Back button. No-op — and pulls no
// @capacitor/app — on web/Telegram/VK, where initNativeShell early-returns. At the
// navigation root (lobby/login) Back exits the app; otherwise it steps history back.
void initNativeShell(() => routeDepth(router.route.name) === 0);
});
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
@@ -143,7 +137,6 @@
<WelcomeRedeemModal />
<Coachmark />
<MaintenanceOverlay />
<UpdateOverlay />
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
-95
View File
@@ -1,95 +0,0 @@
<script lang="ts">
// Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call
// as too old (update.svelte.ts — the client-version gate). Unlike the maintenance overlay this is
// terminal: an installed build cannot become compatible without an actual update, so its one
// action takes the user to the fix — the store listing on a native build (VITE_STORE_URL, opened
// in the system browser / store app) or a plain reload on the web (which fetches the current
// client). Mirrors MaintenanceOverlay.svelte's look.
import { updateRequired } from '../lib/update.svelte';
import { clientChannel } from '../lib/channel';
import { t } from '../lib/i18n/index.svelte';
function onAction(): void {
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') {
// '_system' hands the URL to the OS (the store app / external browser) rather than the WebView.
const url = import.meta.env.VITE_STORE_URL;
if (url) window.open(url, '_system');
} else {
location.reload();
}
}
</script>
{#if updateRequired.active}
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="update-title" aria-describedby="update-body">
<div class="card">
<div class="tile" aria-hidden="true">Э</div>
<h1 id="update-title">{t('update.title')}</h1>
<p id="update-body">{t('update.body')}</p>
<button type="button" onclick={onAction}>{t('update.action')}</button>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
/* Above the game (z 60) and toasts (z 50) — a terminal update block covers everything the user
could otherwise interact with; below the dev DebugPanel (z 10000). */
z-index: 100;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
.card {
max-width: 22rem;
text-align: center;
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2rem 1.5rem;
}
/* Mirrors a placed board tile (as Splash.svelte / MaintenanceOverlay.svelte do). */
.tile {
display: inline-grid;
place-items: center;
width: 3.5rem;
height: 3.5rem;
font-size: 2rem;
font-weight: 700;
border-radius: 4px;
background: var(--tile-bg);
color: var(--tile-text);
box-shadow:
inset 0 -2px 0 var(--tile-edge),
2px 0 3px -1px rgba(0, 0, 0, 0.4);
margin-bottom: 1.25rem;
}
h1 {
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
p {
margin: 0 0 1.5rem;
color: var(--text-muted);
line-height: 1.5;
}
button {
font: inherit;
padding: 0.55rem 1.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text);
cursor: pointer;
}
button:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>
+1 -2
View File
@@ -28,7 +28,6 @@
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
import { gatewayOrigin } from '../lib/origin';
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache';
@@ -1212,7 +1211,7 @@
const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : '';
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
const url = new URL(path, gatewayOrigin()).href;
const url = new URL(path, location.origin).href;
const mime = kind === 'png' ? 'image/png' : 'text/plain';
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
if (insideVK()) {

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