Compare commits
11 Commits
v1.16.0
...
a035edfb54
| Author | SHA1 | Date | |
|---|---|---|---|
| a035edfb54 | |||
| bcd5a1d02d | |||
| a57fd355ba | |||
| 2ea91a8354 | |||
| de003e862a | |||
| aaf2825260 | |||
| 0f5db0ee91 | |||
| 53b33073ac | |||
| e3c5cff7b7 | |||
| 0c5e40c509 | |||
| edaf2dfd9e |
@@ -0,0 +1,211 @@
|
|||||||
|
# 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:00–07: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 (E0–E9). 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).
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
# Native Android (Capacitor) — RuStore MVP: bundle + client-version gate + offline-first
|
||||||
|
|
||||||
|
> Self-contained implementation plan for the standalone Android app, to be executed in a fresh
|
||||||
|
> session (another device). Work through the breakdown A–G in order; each part states its own
|
||||||
|
> "Done when". `PLAN.md` at the repo root is the precedent for a top-level plan doc here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context (why)
|
||||||
|
|
||||||
|
`erudit-game.ru` ships today as a web SPA (Svelte + Vite) served by the gateway, plus Telegram and
|
||||||
|
VK Mini Apps. The owner wants a **standalone Android app**, first on **RuStore**. The stack already
|
||||||
|
declares Capacitor as the target and is pre-seamed for it (feature-detected `window.Capacitor`,
|
||||||
|
`file://`-safe hash router, relative asset base, a reserved native share branch,
|
||||||
|
`viewport-fit=cover`/safe-area layout, a build-time gateway origin var), but **nothing is
|
||||||
|
scaffolded** — no `@capacitor/*` dep, no `capacitor.config.*`, no `android/`.
|
||||||
|
|
||||||
|
Two problems native introduces that the web never had:
|
||||||
|
|
||||||
|
1. **An installed build can be arbitrarily old.** On web every load fetches the current client, so
|
||||||
|
client and server are always paired. In a store a user may run a months-old bundle; if the
|
||||||
|
FlatBuffers wire schema changes incompatibly, an old bundle cannot speak to the server at all.
|
||||||
|
Today there is **no** client↔server version contract — the only "update" path is the web-only
|
||||||
|
`location.reload()`, useless to a bundled APK. → build a **minimum-supported-client gate**.
|
||||||
|
|
||||||
|
2. **First launch must work with no network.** The app must open straight into a guest experience
|
||||||
|
and let the user play locally (vs_ai *and* 2-4-player pass-and-play / hotseat) even if the
|
||||||
|
internet is off on first launch. The current offline mode is a *returning-user* feature: it needs
|
||||||
|
a cached session/profile and server-fetched dictionaries (`offline.ts shouldBootOffline` requires
|
||||||
|
`hasSession && hasProfile`; dicts come from `gateway.fetchDict`). → build **offline-first**:
|
||||||
|
bundle dictionaries in the APK and boot as a device-local guest with no server session.
|
||||||
|
|
||||||
|
**Intended outcome:** a signed APK on RuStore that (a) loads the packaged SPA against the production
|
||||||
|
gateway, (b) is protected by the version gate so future incompatible server changes turn old
|
||||||
|
installs away cleanly, and (c) opens offline-first as a soft guest with local play.
|
||||||
|
|
||||||
|
## Locked decisions (owner interview)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| First store | **RuStore** (Google Play is a later variant — see below) |
|
||||||
|
| Update model | **Bundle** (`dist/` inside the APK) **+ a client-version gate** (no OTA, no remote-URL wrapper) |
|
||||||
|
| First-launch offline | **Offline-first in the MVP**: enter as a local guest, play vs_ai + hotseat with zero network |
|
||||||
|
| Login surface | Guest + email only (already the case — `Login.svelte` shows only these; VK/Telegram auth runs only inside their Mini Apps) |
|
||||||
|
| Payments in MVP | Hidden (deferred; reuse the distribution flag) |
|
||||||
|
| appId (permanent) | **`ru.eruditgame.app`** |
|
||||||
|
| App display name | **`Эрудит`** (Cyrillic) |
|
||||||
|
| Toolchain (locked) | **Capacitor 8** (`@capacitor/*` `^8`); **compileSdk/targetSdk 36, minSdk 24**; **JDK 21** (required — `@capacitor/android` compiles at Java 21; AGP 8.13 / Gradle 8.14.3); Android SDK `platforms;android-36` + `build-tools;36.x` |
|
||||||
|
|
||||||
|
**Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code,
|
||||||
|
commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo
|
||||||
|
rejects standalone spec artifacts, so `ANDROID_PLAN.md` is the only new plan file; do not create
|
||||||
|
`docs/superpowers/specs/*`. Branch model: `feature/android-native` from `development`, PR into
|
||||||
|
`development` (contour review), then promote `development → master`, tag, dispatch the Android build.
|
||||||
|
The gate change is **wire-additive and contour-safe** (a new HTTP header + a new envelope
|
||||||
|
`result_code` string; no FBS/proto regen, no schema migration). The offline-first change is
|
||||||
|
client-only (no server change).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress (as-built)
|
||||||
|
|
||||||
|
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
|
||||||
|
|
||||||
|
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
|
||||||
|
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
|
||||||
|
`ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build` → `cap sync` → `./gradlew assembleDebug`
|
||||||
|
builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator.
|
||||||
|
`svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in
|
||||||
|
`.claude/CLAUDE.md` → "Native Android build (Capacitor)".
|
||||||
|
**Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э»
|
||||||
|
brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive
|
||||||
|
icons + splashes. Superseded by the icon rebrand below.
|
||||||
|
- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts`
|
||||||
|
`gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL,
|
||||||
|
`screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left
|
||||||
|
as-is. Service worker skipped on the native channel (`lib/pwa.svelte.ts`). Payments hidden in the MVP
|
||||||
|
via new `purchasesHidden()` (`lib/distribution.ts`: folds `VITE_PAYMENTS_DISABLED` + the GP flag, plus
|
||||||
|
a `?nopay` mock force); the `Wallet.svelte` buy tab shows a neutral pointer-free note
|
||||||
|
(`wallet.purchasesSoon`) for the RuStore MVP, RuStore stub kept GP-only. Native env types in
|
||||||
|
`vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean;
|
||||||
|
`?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch
|
||||||
|
browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs).
|
||||||
|
- **C. Client-version gate — ✅ DONE & verified (2026-07-12).** Server: new `gateway/internal/clientver`
|
||||||
|
(parse + compare), `GATEWAY_MIN_CLIENT_VERSION` config (empty ⇒ dormant, validated at load), and the
|
||||||
|
gate in `connectsrv` — `Execute` returns `result_code="update_required"` before the registry lookup,
|
||||||
|
`Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client:
|
||||||
|
`X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store +
|
||||||
|
`UpdateOverlay.svelte` (native → `VITE_STORE_URL`, web → reload), `retry.ts` maps
|
||||||
|
`FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go
|
||||||
|
`clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl.
|
||||||
|
`update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app
|
||||||
|
store-update SDK noted Out of scope.
|
||||||
|
- **D. 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.
|
||||||
|
- **E–G — pending.**
|
||||||
|
|
||||||
|
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
|
||||||
|
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
|
||||||
|
the formats drift — `favicon.svg` is a rounded bordered tile, `icon-maskable-512.png` a full-bleed
|
||||||
|
square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable
|
||||||
|
(`capacitor-assets` insets it 16.7% into the adaptive safe zone). `~/.claude/bin/gitea-ci-watch.py` is present.
|
||||||
|
RuStore publication prerequisites gate release only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites to start DEVELOPMENT (on the other device)
|
||||||
|
|
||||||
|
Have all of these before writing code:
|
||||||
|
|
||||||
|
- **JDK 21** (Temurin/OpenJDK) — **required** by Capacitor 8: `@capacitor/android` sets
|
||||||
|
`sourceCompatibility/targetCompatibility = VERSION_21`, so a JDK 17 Gradle run fails with
|
||||||
|
`invalid source release: 21`. (Homebrew: `brew install openjdk@21`.)
|
||||||
|
- **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or*
|
||||||
|
cmdline-tools. Capacitor 8 targets **compileSdk/targetSdk 36, minSdk 24**, so install:
|
||||||
|
`platform-tools`, `platforms;android-36`, `build-tools;36.0.0` (a newer build-tools such as
|
||||||
|
`36.1.0` is also accepted — AGP treats it as a minimum). Gradle 8.14.3 / AGP 8.13 arrive via the
|
||||||
|
wrapper `cap add android` generates.
|
||||||
|
- **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain.
|
||||||
|
- **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login
|
||||||
|
and the CI watcher exactly as the owner's global setup (`~/.claude` tooling:
|
||||||
|
`python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`).
|
||||||
|
- **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the
|
||||||
|
sibling `scrabble-solver` next to it — the solver **library** the backend/CI consume via the
|
||||||
|
`go.work` replace. **The bundled dictionaries do NOT come from the solver.** The versioned,
|
||||||
|
production dictionary set is published by `developer/scrabble-dictionary` as a **release artifact**
|
||||||
|
`scrabble-dawg-<DICT_VERSION>.tar.gz` (one semver label per set); `backend/Dockerfile` and every CI
|
||||||
|
job already `curl` exactly that tarball, keyed on the shared Gitea variable `DICT_VERSION`.
|
||||||
|
Offline-first bundles the DAWGs from that same release (see D.1 / E), so the Android build needs
|
||||||
|
`DICT_VERSION` + network access to the release, **not** `scrabble-solver`. (`scrabble-solver/dawg/*.dawg`
|
||||||
|
are the solver's committed test fixtures — byte-identical to a build but pinned to the solver
|
||||||
|
commit, not the versioned production set.)
|
||||||
|
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. a
|
||||||
|
Pixel AVD; any recent API image).
|
||||||
|
- **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway
|
||||||
|
is only needed to exercise the version gate locally (optional).
|
||||||
|
|
||||||
|
Publication prerequisites (RuStore account, keystore, listing assets) are listed at the **end** —
|
||||||
|
they gate *release*, not development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity model (answers "how do guests work offline vs in the DB")
|
||||||
|
|
||||||
|
Split the **local play identity** from the **server account**. This *extends* the current model
|
||||||
|
(guests are DB rows) with a pre-server local state; existing server-guest semantics are unchanged.
|
||||||
|
|
||||||
|
- **Local guest (device-local, no DB row).** A device-generated id + a default display name,
|
||||||
|
persisted on the device. Exists from the very first launch, with no network. It fills the human
|
||||||
|
seat in a local vs_ai game and is the "you" for local games. A purely-offline user never creates a
|
||||||
|
DB row (consumes no server resources).
|
||||||
|
- **Server guest (DB row).** Minted lazily via `auth.guest` the first time the app reaches the
|
||||||
|
network, its session cached and reused. Unlocks online features (matchmaking, friends, online
|
||||||
|
games). Exactly one per device — guarded by the cached session (never mint if one exists).
|
||||||
|
- **Registered account.** Email upgrade as today; adopts the current server guest.
|
||||||
|
- **Local games stay device-only.** Both local vs_ai and local hotseat games already persist only on
|
||||||
|
the device (`ui/src/lib/localgame/store.ts`); they never sync to the server, regardless of identity
|
||||||
|
transitions. Reconciliation to a server guest does **not** migrate them.
|
||||||
|
|
||||||
|
**Reconciliation flow:** on gaining network with no server session, silently `auth.guest` in the
|
||||||
|
background, cache the session, adopt it. This is best-effort — see the gate interaction below for how
|
||||||
|
a too-old client stays offline instead of being interrupted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The client-version gate — architecture (read before touching gate code)
|
||||||
|
|
||||||
|
**Principle: the version rides the outermost, permanently-stable layer, never the FlatBuffers
|
||||||
|
payload.** The transport is two layers: a protobuf Connect envelope
|
||||||
|
(`ExecuteRequest{message_type, payload, request_id}`, `gateway/proto/edge/v1/edge.proto`) wrapping a
|
||||||
|
FlatBuffers payload (`pkg/fbs/scrabble.fbs`). Protobuf envelopes and HTTP headers are
|
||||||
|
version-tolerant by design; the FBS payload is the layer that breaks. So the client version rides in
|
||||||
|
an **HTTP header `X-Client-Version`**, read by the gateway **before it decodes the payload**.
|
||||||
|
|
||||||
|
**Enforcement is per-call, not a dedicated init RPC.** The client attaches the header to every
|
||||||
|
Connect call via its single `headers()` builder; the gateway checks it at the top of `Execute`
|
||||||
|
(before registry lookup / auth / payload decode) and `Subscribe`. The first online call the app makes
|
||||||
|
(session establish `auth.*`) is thus gated automatically — no `Hello` RPC needed. A too-old client
|
||||||
|
makes **zero** successful requests but sees a recognizable signal, not a crash.
|
||||||
|
|
||||||
|
**Two return shapes, one meaning:**
|
||||||
|
- `Execute` → the domain-style envelope `result_code = "update_required"` (HTTP 200); the client
|
||||||
|
already throws `GatewayError(result_code)` for any non-`ok`.
|
||||||
|
- `Subscribe` → Connect `CodeFailedPrecondition` (a stream has no `result_code`).
|
||||||
|
|
||||||
|
**Frozen contract (write into `docs/ARCHITECTURE.md` §2):** three things become permanent so any
|
||||||
|
build, however old, can always recognize "update required": (1) the protobuf envelope field numbers
|
||||||
|
in `edge.proto` are never renumbered/reused; (2) the `update_required` sentinel (the `result_code`
|
||||||
|
string + the `FailedPrecondition` code) never changes; (3) the FBS schema stays **additive**
|
||||||
|
(trailing fields only, `(deprecated)` never delete). Breaking changes happen only *inside* the FBS
|
||||||
|
payload.
|
||||||
|
|
||||||
|
**Discipline (write into `deploy/README.md`):** the production deploy that ships an incompatible wire
|
||||||
|
change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same rollout. Until
|
||||||
|
deliberately set, `GATEWAY_MIN_CLIENT_VERSION` is **empty ⇒ the gate is dormant and web behaviour is
|
||||||
|
unchanged.** One hard threshold for the MVP; a soft "recommended update" tier is deferred.
|
||||||
|
|
||||||
|
**Interaction with offline-first (important UX rule):** offline mode uses the network kill switch
|
||||||
|
(`transport.ts assertOnline`), so the gate never fires while playing offline — an old client can
|
||||||
|
always play local vs_ai/hotseat. The gate matters only for online actions. Therefore the terminal
|
||||||
|
"update" overlay must be raised **only on a user-initiated online action**, never on the silent
|
||||||
|
background guest-reconciliation: if reconciliation's `auth.guest` returns `update_required`, swallow
|
||||||
|
it and stay a local guest (do not overlay). Implementation seam: the reconciliation path catches the
|
||||||
|
`update_required` code and does not route it to the global overlay trigger; foreground calls do.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Work breakdown
|
||||||
|
|
||||||
|
Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence
|
||||||
|
is: scaffold → native-correct → gate → offline-first → CI → docs → release.
|
||||||
|
|
||||||
|
### A. Capacitor scaffolding — ✅ DONE
|
||||||
|
|
||||||
|
- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps
|
||||||
|
`@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`,
|
||||||
|
`"android:assets": "capacitor-assets generate --android"`.
|
||||||
|
- `ui/capacitor.config.ts` (new):
|
||||||
|
```ts
|
||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'ru.eruditgame.app',
|
||||||
|
appName: 'Эрудит',
|
||||||
|
webDir: 'dist',
|
||||||
|
// Bundle model: no server.url — the WebView loads the packaged dist/ from app assets. Updates
|
||||||
|
// ship through the store; the client-version gate turns away a build too old to speak the
|
||||||
|
// current wire contract (docs/ARCHITECTURE.md §2).
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
- `npx cap add android` → generates and **commits** `ui/android/` (a Gradle project). Neither
|
||||||
|
`.gitignore` lists it today; keep it tracked so build.gradle + signing edits are reviewable and CI
|
||||||
|
reproducible. Ensure `ui/android/.gitignore` covers build outputs (`/app/build`, `/build`,
|
||||||
|
`/.gradle`, `/local.properties`, `*.keystore`, `*.jks`).
|
||||||
|
- **Android hardware Back** — new `ui/src/lib/native.ts`, dynamically importing `@capacitor/app` so
|
||||||
|
the web/mock bundle never pulls it:
|
||||||
|
```ts
|
||||||
|
import { clientChannel } from './channel';
|
||||||
|
export async function initNativeShell(atNavigationRoot: () => boolean): Promise<void> {
|
||||||
|
const ch = clientChannel();
|
||||||
|
if (ch !== 'android' && ch !== 'ios') return;
|
||||||
|
const { App } = await import('@capacitor/app');
|
||||||
|
App.addListener('backButton', () => {
|
||||||
|
if (atNavigationRoot()) void App.exitApp();
|
||||||
|
else history.back();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
|
||||||
|
(mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`).
|
||||||
|
- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png`
|
||||||
|
(+ optional `splash.png`); `pnpm android:assets` generates adaptive icons.
|
||||||
|
|
||||||
|
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
|
||||||
|
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
|
||||||
|
|
||||||
|
### B. Native web-build correctness (file:// origin) — ✅ DONE
|
||||||
|
|
||||||
|
1. **One origin helper** — new `ui/src/lib/origin.ts`:
|
||||||
|
```ts
|
||||||
|
// The absolute origin the client talks to. A packaged native build (file:// origin) sets
|
||||||
|
// VITE_GATEWAY_URL to the real gateway; web/mock leave it empty and fall back to the page
|
||||||
|
// origin. Centralised so every absolute-URL construction resolves identically.
|
||||||
|
export function gatewayOrigin(): string {
|
||||||
|
const configured = import.meta.env.VITE_GATEWAY_URL ?? '';
|
||||||
|
return configured || (typeof location !== 'undefined' ? location.origin : '');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Fix the two same-origin **landmines** (they would produce `file:///…` on native):
|
||||||
|
- `ui/src/game/Game.svelte:1214`: `new URL(path, location.origin)` → `new URL(path, gatewayOrigin())`.
|
||||||
|
- `ui/src/screens/Wallet.svelte:47`: `` `${location.origin}/` `` → `` `${gatewayOrigin()}/` ``.
|
||||||
|
(`transport.ts:32` already computes the equivalent inline — leave it.)
|
||||||
|
2. **Skip the service worker on native** — `ui/src/lib/pwa.svelte.ts registerServiceWorker()` (line 85):
|
||||||
|
add, next to `if (inMiniApp()) return;`:
|
||||||
|
```ts
|
||||||
|
import { clientChannel } from './channel';
|
||||||
|
const ch = clientChannel();
|
||||||
|
if (ch === 'android' || ch === 'ios') return;
|
||||||
|
```
|
||||||
|
3. **Hide payments in the MVP build** — `ui/src/lib/distribution.ts`, add:
|
||||||
|
```ts
|
||||||
|
// purchasesHidden: true for the Google Play build (external-payment policy) OR the thin native
|
||||||
|
// MVP that defers store billing (VITE_PAYMENTS_DISABLED="1"). Every other build sells normally.
|
||||||
|
export function purchasesHidden(): boolean {
|
||||||
|
return isGooglePlayBuild() || (import.meta.env as Record<string, string | undefined>).VITE_PAYMENTS_DISABLED === '1';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
|
||||||
|
hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub
|
||||||
|
`isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() &&
|
||||||
|
!isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key
|
||||||
|
`wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the
|
||||||
|
same note can replace the RuStore stub in the later Google Play anti-steering variant.
|
||||||
|
`purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the
|
||||||
|
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
|
||||||
|
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
|
||||||
|
4. **Native env matrix** — see Build & env.
|
||||||
|
|
||||||
|
**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the
|
||||||
|
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
||||||
|
|
||||||
|
### C. The client-version gate — ✅ DONE
|
||||||
|
|
||||||
|
#### C1. Backend (gateway)
|
||||||
|
|
||||||
|
- **New package `gateway/internal/clientver`** (`clientver.go` + `_test.go`): dependency-free parse of
|
||||||
|
the leading `v?MAJOR.MINOR.PATCH` (ignore any `-N-gSHA`/`+meta` suffix) + compare:
|
||||||
|
```go
|
||||||
|
package clientver
|
||||||
|
import ("strconv"; "strings")
|
||||||
|
type Version struct{ Major, Minor, Patch int }
|
||||||
|
func Parse(s string) (Version, bool) {
|
||||||
|
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||||
|
if i := strings.IndexAny(s, "-+"); i >= 0 { s = s[:i] }
|
||||||
|
p := strings.SplitN(s, ".", 4)
|
||||||
|
if len(p) < 3 { return Version{}, false }
|
||||||
|
var v Version; var err error
|
||||||
|
if v.Major, err = strconv.Atoi(p[0]); err != nil { return Version{}, false }
|
||||||
|
if v.Minor, err = strconv.Atoi(p[1]); err != nil { return Version{}, false }
|
||||||
|
if v.Patch, err = strconv.Atoi(p[2]); err != nil { return Version{}, false }
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
func Less(a, b Version) bool {
|
||||||
|
if a.Major != b.Major { return a.Major < b.Major }
|
||||||
|
if a.Minor != b.Minor { return a.Minor < b.Minor }
|
||||||
|
return a.Patch < b.Patch
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Tests: `"v1.16.0"`, `"1.16.0"`, `"v1.16.0-3-gabc"`, `"dev"`/`""`→!ok, ordering incl. equal.
|
||||||
|
- **Config `gateway/internal/config/config.go`**: add `MinClientVersion string` (doc: empty ⇒ gate
|
||||||
|
off); `Load()`: `c.MinClientVersion = os.Getenv("GATEWAY_MIN_CLIENT_VERSION")`; `validate()`: if
|
||||||
|
non-empty and `!clientver.Parse(...ok)` → return a config error. Extend `config_test.go`.
|
||||||
|
- **Server `gateway/internal/connectsrv/server.go`**:
|
||||||
|
- consts `clientVersionHeader = "X-Client-Version"`, `resultUpdateRequired = "update_required"`;
|
||||||
|
`errors.go`: `errUpdateRequired = errors.New("client too old, update required")`.
|
||||||
|
- `Server`: add `minClient clientver.Version` + `gateOn bool`. `Deps`: add `MinClientVersion string`.
|
||||||
|
`NewServer`: parse `d.MinClientVersion` once (set `gateOn` on success; `log.Warn` + off if
|
||||||
|
unparseable).
|
||||||
|
- helper `clientTooOld(header string) bool`: `if !s.gateOn { return false }`; parse header, on
|
||||||
|
`!ok` return false (fail-open); return `clientver.Less(v, s.minClient)`.
|
||||||
|
- `Execute`: insert **after** the `defer` metrics line (after `server.go:296`, before
|
||||||
|
`registry.Lookup`), so `msgType` is set and the payload is untouched:
|
||||||
|
```go
|
||||||
|
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
|
||||||
|
result = resultUpdateRequired
|
||||||
|
return connect.NewResponse(&edgev1.ExecuteResponse{
|
||||||
|
RequestId: req.Msg.GetRequestId(), ResultCode: resultUpdateRequired,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `Subscribe`: at the top (before `resolve`): `if s.clientTooOld(req.Header().Get(clientVersionHeader)) { return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired) }`.
|
||||||
|
- `main.go`: add `MinClientVersion: cfg.MinClientVersion,` to the `connectsrv.Deps{...}` literal.
|
||||||
|
- Tests (`server_test.go`): too-old Execute ⇒ `result_code == "update_required"` and the op handler
|
||||||
|
never ran; too-old Subscribe ⇒ `FailedPrecondition`; absent header / empty min / unparseable
|
||||||
|
header / equal version ⇒ pass.
|
||||||
|
|
||||||
|
#### C2. Client (ui)
|
||||||
|
|
||||||
|
- **`ui/src/lib/update.svelte.ts`** (new, terminal — no poll):
|
||||||
|
```ts
|
||||||
|
export const UPDATE_REQUIRED = 'update_required';
|
||||||
|
let required = $state(false);
|
||||||
|
export const updateRequired = { get active(): boolean { return required; } };
|
||||||
|
export function reportUpdateRequired(): void { required = true; }
|
||||||
|
```
|
||||||
|
- **`ui/src/lib/transport.ts`**:
|
||||||
|
- `headers()` (line 37) always attaches the version header:
|
||||||
|
```ts
|
||||||
|
const headers = (): Record<string, string> => {
|
||||||
|
const h: Record<string, string> = { 'x-client-version': __APP_VERSION__ };
|
||||||
|
if (token) h.authorization = `Bearer ${token}`;
|
||||||
|
return h;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
- result-code branch (line 86): `if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired();`
|
||||||
|
before the throw.
|
||||||
|
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
|
||||||
|
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
|
||||||
|
- **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant
|
||||||
|
(swallows `update_required` without raising the overlay) has no caller until D.4, so it is added
|
||||||
|
there with its caller rather than speculatively in C. In C every foreground call that gets
|
||||||
|
`update_required` raises the overlay; offline play never trips it (the network kill switch).
|
||||||
|
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
|
||||||
|
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
|
||||||
|
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
|
||||||
|
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
|
||||||
|
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys
|
||||||
|
`update.title/body/action` (add siblings to the maintenance keys).
|
||||||
|
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
|
||||||
|
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
|
||||||
|
- **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via
|
||||||
|
Playwright `update.spec.ts` (`__update.on()`). The `update.svelte.ts` store is a `$state` rune module,
|
||||||
|
which this project's plugin-less `vitest` cannot import (`$state is not defined`); like every other
|
||||||
|
`*.svelte.ts` store it is covered by the e2e, not a unit test.
|
||||||
|
|
||||||
|
**Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local
|
||||||
|
gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
|
||||||
|
|
||||||
|
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) — 🚧 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.
|
||||||
@@ -156,3 +156,11 @@ 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/`
|
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
|
||||||
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
`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
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
AdminProxy: adminProxy,
|
AdminProxy: adminProxy,
|
||||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
||||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||||
|
MinClientVersion: cfg.MinClientVersion,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Package clientver parses and compares the leading MAJOR.MINOR.PATCH of a client
|
||||||
|
// version string so the edge can turn away a build too old to speak the current wire
|
||||||
|
// contract. It is deliberately dependency-free and tolerant: the version rides an HTTP
|
||||||
|
// header (X-Client-Version) that a build stamps from `git describe --tags`, so any
|
||||||
|
// `-N-gSHA` or `+meta` suffix is ignored, and anything unparseable is reported as such
|
||||||
|
// (the caller fails open — an absent or garbled header is never treated as too old).
|
||||||
|
package clientver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is a parsed semantic version triple. Only MAJOR.MINOR.PATCH participate in the
|
||||||
|
// ordering; any pre-release or build suffix is dropped at parse time.
|
||||||
|
type Version struct {
|
||||||
|
Major, Minor, Patch int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse extracts the leading MAJOR.MINOR.PATCH from s, tolerating an optional leading
|
||||||
|
// "v" and any `-N-gSHA` or `+meta` suffix (as produced by `git describe --tags`). It
|
||||||
|
// reports ok=false when s has fewer than three numeric components or any component is not
|
||||||
|
// an integer, so the caller can distinguish a real version from a dev/empty string.
|
||||||
|
func Parse(s string) (Version, bool) {
|
||||||
|
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||||
|
if i := strings.IndexAny(s, "-+"); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
p := strings.SplitN(s, ".", 4)
|
||||||
|
if len(p) < 3 {
|
||||||
|
return Version{}, false
|
||||||
|
}
|
||||||
|
var v Version
|
||||||
|
var err error
|
||||||
|
if v.Major, err = strconv.Atoi(p[0]); err != nil {
|
||||||
|
return Version{}, false
|
||||||
|
}
|
||||||
|
if v.Minor, err = strconv.Atoi(p[1]); err != nil {
|
||||||
|
return Version{}, false
|
||||||
|
}
|
||||||
|
if v.Patch, err = strconv.Atoi(p[2]); err != nil {
|
||||||
|
return Version{}, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Less reports whether a orders before b by MAJOR, then MINOR, then PATCH. Equal versions
|
||||||
|
// are not Less than each other, so a client exactly at the minimum passes the gate.
|
||||||
|
func Less(a, b Version) bool {
|
||||||
|
if a.Major != b.Major {
|
||||||
|
return a.Major < b.Major
|
||||||
|
}
|
||||||
|
if a.Minor != b.Minor {
|
||||||
|
return a.Minor < b.Minor
|
||||||
|
}
|
||||||
|
return a.Patch < b.Patch
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package clientver
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestParse covers the version strings the edge actually sees: a plain and a "v"-prefixed
|
||||||
|
// triple, a `git describe --tags` suffix, build metadata, surrounding space, and the
|
||||||
|
// non-version strings (dev/empty/too-few/non-numeric) that must report ok=false so the
|
||||||
|
// gate fails open.
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want Version
|
||||||
|
wantK bool
|
||||||
|
}{
|
||||||
|
{"plain", "1.16.0", Version{1, 16, 0}, true},
|
||||||
|
{"v-prefixed", "v1.16.0", Version{1, 16, 0}, true},
|
||||||
|
{"git describe suffix", "v1.16.0-3-gabc1234", Version{1, 16, 0}, true},
|
||||||
|
{"build metadata", "1.16.0+ci42", Version{1, 16, 0}, true},
|
||||||
|
{"surrounding space", " v2.0.1 ", Version{2, 0, 1}, true},
|
||||||
|
{"extra component ignored", "v1.16.0.4", Version{1, 16, 0}, true},
|
||||||
|
{"dev", "dev", Version{}, false},
|
||||||
|
{"empty", "", Version{}, false},
|
||||||
|
{"too few components", "1.16", Version{}, false},
|
||||||
|
{"non-numeric patch", "1.16.x", Version{}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, ok := Parse(tc.in)
|
||||||
|
if ok != tc.wantK {
|
||||||
|
t.Fatalf("Parse(%q) ok = %v, want %v", tc.in, ok, tc.wantK)
|
||||||
|
}
|
||||||
|
if ok && got != tc.want {
|
||||||
|
t.Errorf("Parse(%q) = %+v, want %+v", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLess covers the ordering by MAJOR, then MINOR, then PATCH, and that an equal version
|
||||||
|
// is not Less (a client exactly at the minimum passes the gate).
|
||||||
|
func TestLess(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
a, b Version
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"equal", Version{1, 16, 0}, Version{1, 16, 0}, false},
|
||||||
|
{"major less", Version{1, 9, 9}, Version{2, 0, 0}, true},
|
||||||
|
{"major greater", Version{2, 0, 0}, Version{1, 9, 9}, false},
|
||||||
|
{"minor less", Version{1, 16, 5}, Version{1, 17, 0}, true},
|
||||||
|
{"minor greater", Version{1, 17, 0}, Version{1, 16, 9}, false},
|
||||||
|
{"patch less", Version{1, 16, 0}, Version{1, 16, 1}, true},
|
||||||
|
{"patch greater", Version{1, 16, 2}, Version{1, 16, 1}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := Less(tc.a, tc.b); got != tc.want {
|
||||||
|
t.Errorf("Less(%+v, %+v) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/clientver"
|
||||||
pkgtel "scrabble/pkg/telemetry"
|
pkgtel "scrabble/pkg/telemetry"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +55,11 @@ type Config struct {
|
|||||||
// MaxBodyBytes caps one inbound request body on the public listener and one
|
// MaxBodyBytes caps one inbound request body on the public listener and one
|
||||||
// Connect message read; oversized requests are refused without buffering.
|
// Connect message read; oversized requests are refused without buffering.
|
||||||
MaxBodyBytes int
|
MaxBodyBytes int
|
||||||
|
// MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
|
||||||
|
// the edge serves. A client reporting an older X-Client-Version is turned away with an
|
||||||
|
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
|
||||||
|
// (every client is served) — the default for web-only deployments.
|
||||||
|
MinClientVersion string
|
||||||
// RateLimit configures the in-memory anti-abuse limiter.
|
// RateLimit configures the in-memory anti-abuse limiter.
|
||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
||||||
@@ -234,6 +240,7 @@ func Load() (Config, error) {
|
|||||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||||
|
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
|
||||||
VKID: VKIDConfig{
|
VKID: VKIDConfig{
|
||||||
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
||||||
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
||||||
@@ -332,6 +339,11 @@ func (c Config) validate() error {
|
|||||||
if c.MaxBodyBytes <= 0 {
|
if c.MaxBodyBytes <= 0 {
|
||||||
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
|
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
|
||||||
}
|
}
|
||||||
|
if c.MinClientVersion != "" {
|
||||||
|
if _, ok := clientver.Parse(c.MinClientVersion); !ok {
|
||||||
|
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
|
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
|
||||||
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,29 @@ 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),
|
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
|
||||||
// the agreed thresholds, and no honeytoken.
|
// the agreed thresholds, and no honeytoken.
|
||||||
func TestLoadAbuseDefaults(t *testing.T) {
|
func TestLoadAbuseDefaults(t *testing.T) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ var (
|
|||||||
errInternal = errors.New("internal error")
|
errInternal = errors.New("internal error")
|
||||||
errMissingToken = errors.New("missing session token")
|
errMissingToken = errors.New("missing session token")
|
||||||
errInvalidSession = errors.New("invalid or expired session")
|
errInvalidSession = errors.New("invalid or expired session")
|
||||||
|
errUpdateRequired = errors.New("client too old, update required")
|
||||||
)
|
)
|
||||||
|
|
||||||
// errUnknownMessageType reports an unregistered message type.
|
// errUnknownMessageType reports an unregistered message type.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"golang.org/x/net/http2/h2c"
|
"golang.org/x/net/http2/h2c"
|
||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
|
"scrabble/gateway/internal/clientver"
|
||||||
"scrabble/gateway/internal/config"
|
"scrabble/gateway/internal/config"
|
||||||
"scrabble/gateway/internal/push"
|
"scrabble/gateway/internal/push"
|
||||||
"scrabble/gateway/internal/ratelimit"
|
"scrabble/gateway/internal/ratelimit"
|
||||||
@@ -46,6 +47,16 @@ const heartbeatKind = "heartbeat"
|
|||||||
// spoofer, so trusting it is safe.
|
// spoofer, so trusting it is safe.
|
||||||
const honeypotHeader = "X-Scrabble-Honeypot"
|
const honeypotHeader = "X-Scrabble-Honeypot"
|
||||||
|
|
||||||
|
// clientVersionHeader carries the client build version (stamped from `git describe --tags`) so
|
||||||
|
// the edge can turn away a build too old to speak the current wire contract, before it decodes
|
||||||
|
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
|
||||||
|
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
|
||||||
|
// "you must update". 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
|
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||||
// class field of the periodic rejection report.
|
// class field of the periodic rejection report.
|
||||||
const (
|
const (
|
||||||
@@ -88,6 +99,11 @@ type Server struct {
|
|||||||
|
|
||||||
maxBodyBytes int
|
maxBodyBytes int
|
||||||
|
|
||||||
|
// minClient is the lowest client version served; gateOn is false when the gate is dormant
|
||||||
|
// (GATEWAY_MIN_CLIENT_VERSION empty or unparseable).
|
||||||
|
minClient clientver.Version
|
||||||
|
gateOn bool
|
||||||
|
|
||||||
publicPolicy ratelimit.Policy
|
publicPolicy ratelimit.Policy
|
||||||
userPolicy ratelimit.Policy
|
userPolicy ratelimit.Policy
|
||||||
emailPolicy ratelimit.Policy
|
emailPolicy ratelimit.Policy
|
||||||
@@ -128,6 +144,10 @@ type Deps struct {
|
|||||||
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
||||||
// zero or negative selects config.DefaultMaxBodyBytes.
|
// zero or negative selects config.DefaultMaxBodyBytes.
|
||||||
MaxBodyBytes int
|
MaxBodyBytes int
|
||||||
|
// MinClientVersion is the lowest client version (MAJOR.MINOR.PATCH) the edge serves; an
|
||||||
|
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
|
||||||
|
// the gate dormant.
|
||||||
|
MinClientVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs the edge service.
|
// NewServer constructs the edge service.
|
||||||
@@ -160,6 +180,18 @@ func NewServer(d Deps) *Server {
|
|||||||
if rl == (config.RateLimitConfig{}) {
|
if rl == (config.RateLimitConfig{}) {
|
||||||
rl = config.DefaultRateLimit()
|
rl = config.DefaultRateLimit()
|
||||||
}
|
}
|
||||||
|
// Parse the minimum client version once. Config.validate already rejects an unparseable
|
||||||
|
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
|
||||||
|
// the gate dormant.
|
||||||
|
var minClient clientver.Version
|
||||||
|
gateOn := false
|
||||||
|
if d.MinClientVersion != "" {
|
||||||
|
if v, ok := clientver.Parse(d.MinClientVersion); ok {
|
||||||
|
minClient, gateOn = v, true
|
||||||
|
} else {
|
||||||
|
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
|
||||||
|
}
|
||||||
|
}
|
||||||
return &Server{
|
return &Server{
|
||||||
registry: d.Registry,
|
registry: d.Registry,
|
||||||
sessions: d.Sessions,
|
sessions: d.Sessions,
|
||||||
@@ -176,6 +208,8 @@ func NewServer(d Deps) *Server {
|
|||||||
adminProxy: d.AdminProxy,
|
adminProxy: d.AdminProxy,
|
||||||
metrics: newServerMetrics(d.Meter, blocklist),
|
metrics: newServerMetrics(d.Meter, blocklist),
|
||||||
maxBodyBytes: maxBody,
|
maxBodyBytes: maxBody,
|
||||||
|
minClient: minClient,
|
||||||
|
gateOn: gateOn,
|
||||||
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
||||||
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
||||||
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
|
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
|
||||||
@@ -285,6 +319,22 @@ 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
|
// Execute runs one unary operation. Domain failures are returned in the envelope
|
||||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||||
// session, unknown type, internal) become Connect errors.
|
// session, unknown type, internal) become Connect errors.
|
||||||
@@ -294,6 +344,17 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
result := "internal"
|
result := "internal"
|
||||||
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
|
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)
|
op, ok := s.registry.Lookup(msgType)
|
||||||
if !ok {
|
if !ok {
|
||||||
result = "unknown_type"
|
result = "unknown_type"
|
||||||
@@ -363,6 +424,11 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
// Subscribe streams the authenticated user's live events with a keep-alive
|
// Subscribe streams the authenticated user's live events with a keep-alive
|
||||||
// heartbeat until the client disconnects.
|
// heartbeat until the client disconnects.
|
||||||
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
||||||
|
// A stream carries no result_code, so a too-old client is refused with the Connect
|
||||||
|
// FailedPrecondition counterpart of the update_required sentinel.
|
||||||
|
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
|
||||||
|
return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired)
|
||||||
|
}
|
||||||
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
|
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
|
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
|
||||||
// client plus a cleanup func.
|
// client plus a cleanup func. The client-version gate is off.
|
||||||
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
||||||
|
return newEdgeMin(t, "", backendHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off).
|
||||||
|
func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
backendSrv := httptest.NewServer(backendHandler)
|
backendSrv := httptest.NewServer(backendHandler)
|
||||||
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
||||||
@@ -37,6 +42,7 @@ func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.Gatew
|
|||||||
Hub: push.NewHub(0),
|
Hub: push.NewHub(0),
|
||||||
RateLimit: config.DefaultRateLimit(),
|
RateLimit: config.DefaultRateLimit(),
|
||||||
Heartbeat: 15 * time.Second,
|
Heartbeat: 15 * time.Second,
|
||||||
|
MinClientVersion: minVersion,
|
||||||
})
|
})
|
||||||
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
||||||
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
||||||
@@ -108,6 +114,84 @@ 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) {
|
func TestExecuteAuthedRequiresSession(t *testing.T) {
|
||||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Error("backend must not be called without a session")
|
t.Error("backend must not be called without a session")
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Release signing material must never be committed (see ANDROID_PLAN.md — keystore loss/leak risk).
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// 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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package ru.eruditgame.app;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {}
|
||||||
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 913 B |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 754 B |
|
After Width: | Height: | Size: 333 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 564 B |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Эрудит</string>
|
||||||
|
<string name="title_activity_main">Эрудит</string>
|
||||||
|
<string name="package_name">ru.eruditgame.app</string>
|
||||||
|
<string name="custom_url_scheme">ru.eruditgame.app</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// 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')
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ext {
|
||||||
|
minSdkVersion = 24
|
||||||
|
compileSdkVersion = 36
|
||||||
|
targetSdkVersion = 36
|
||||||
|
androidxActivityVersion = '1.11.0'
|
||||||
|
androidxAppCompatVersion = '1.7.1'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||||
|
androidxCoreVersion = '1.17.0'
|
||||||
|
androidxFragmentVersion = '1.8.9'
|
||||||
|
coreSplashScreenVersion = '1.2.0'
|
||||||
|
androidxWebkitVersion = '1.14.0'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.3.0'
|
||||||
|
androidxEspressoCoreVersion = '3.7.0'
|
||||||
|
cordovaAndroidVersion = '14.0.1'
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
@@ -38,7 +38,20 @@ async function openFinishedHistory(page: Page): Promise<void> {
|
|||||||
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
|
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 }) => {
|
test('the chooser downloads the PNG through the signed URL on a plain browser', async ({ page }) => {
|
||||||
|
await withoutWebShare(page);
|
||||||
await routeDl(page);
|
await routeDl(page);
|
||||||
await openFinishedHistory(page);
|
await openFinishedHistory(page);
|
||||||
await page.getByRole('button', { name: 'Export game' }).click();
|
await page.getByRole('button', { name: 'Export game' }).click();
|
||||||
@@ -49,6 +62,7 @@ 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 }) => {
|
test('the chooser downloads the GCG through the same signed-URL route', async ({ page }) => {
|
||||||
|
await withoutWebShare(page);
|
||||||
await routeDl(page);
|
await routeDl(page);
|
||||||
await openFinishedHistory(page);
|
await openFinishedHistory(page);
|
||||||
await page.getByRole('button', { name: 'Export game' }).click();
|
await page.getByRole('button', { name: 'Export game' }).click();
|
||||||
@@ -198,6 +212,7 @@ 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 }) => {
|
test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => {
|
||||||
|
await withoutWebShare(page);
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
// Headless engines deny the real clipboard; a permissive stub keeps the legacy
|
// Headless engines deny the real clipboard; a permissive stub keeps the legacy
|
||||||
// copy path deterministic in both browsers.
|
// copy path deterministic in both browsers.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
@@ -105,6 +105,22 @@ test('wallet: the Google Play build hides the money purchases behind the RuStore
|
|||||||
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
|
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 }) => {
|
test('wallet: a web spend that would draw store chips confirms with a warning, then completes', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openWallet(page);
|
await openWallet(page);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"start": "vite --mode mock",
|
"start": "vite --mode mock",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"cap:sync": "cap sync android",
|
||||||
|
"android:assets": "capacitor-assets generate --android",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
"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",
|
"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",
|
"test:unit": "vitest run",
|
||||||
@@ -17,6 +19,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.12.0",
|
"@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": "^2.1.0",
|
||||||
"@connectrpc/connect-web": "^2.1.0",
|
"@connectrpc/connect-web": "^2.1.0",
|
||||||
"@vkontakte/vk-bridge": "^3.0.2",
|
"@vkontakte/vk-bridge": "^3.0.2",
|
||||||
@@ -24,6 +29,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bufbuild/protoc-gen-es": "^2.12.0",
|
"@bufbuild/protoc-gen-es": "^2.12.0",
|
||||||
|
"@capacitor/assets": "^3.0.5",
|
||||||
|
"@capacitor/cli": "^8.4.1",
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|||||||
@@ -6,3 +6,7 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
core-js-bundle: false
|
core-js-bundle: false
|
||||||
esbuild: true
|
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
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// 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})`);
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
|
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
|
||||||
import { router, type RouteName } from './lib/router.svelte';
|
import { router, type RouteName } from './lib/router.svelte';
|
||||||
import { t } from './lib/i18n/index.svelte';
|
import { t } from './lib/i18n/index.svelte';
|
||||||
|
import { initNativeShell } from './lib/native';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
import Splash from './components/Splash.svelte';
|
import Splash from './components/Splash.svelte';
|
||||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
import Coachmark from './components/Coachmark.svelte';
|
import Coachmark from './components/Coachmark.svelte';
|
||||||
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
|
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
|
||||||
|
import UpdateOverlay from './components/UpdateOverlay.svelte';
|
||||||
import DebugPanel from './components/DebugPanel.svelte';
|
import DebugPanel from './components/DebugPanel.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
@@ -32,6 +34,10 @@
|
|||||||
void bootstrap().then(() => {
|
void bootstrap().then(() => {
|
||||||
(window as unknown as { __booted?: boolean }).__booted = true;
|
(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
|
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
|
||||||
@@ -137,6 +143,7 @@
|
|||||||
<WelcomeRedeemModal />
|
<WelcomeRedeemModal />
|
||||||
<Coachmark />
|
<Coachmark />
|
||||||
<MaintenanceOverlay />
|
<MaintenanceOverlay />
|
||||||
|
<UpdateOverlay />
|
||||||
|
|
||||||
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
|
<!-- 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
|
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<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>
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
|
import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
|
||||||
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
||||||
|
import { gatewayOrigin } from '../lib/origin';
|
||||||
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
||||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||||
import { patchLobbyGame } from '../lib/lobbycache';
|
import { patchLobbyGame } from '../lib/lobbycache';
|
||||||
@@ -1211,7 +1212,7 @@
|
|||||||
const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : '';
|
const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : '';
|
||||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
|
||||||
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
|
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
|
||||||
const url = new URL(path, location.origin).href;
|
const url = new URL(path, gatewayOrigin()).href;
|
||||||
const mime = kind === 'png' ? 'image/png' : 'text/plain';
|
const mime = kind === 'png' ? 'image/png' : 'text/plain';
|
||||||
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
|
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
|
||||||
if (insideVK()) {
|
if (insideVK()) {
|
||||||
|
|||||||