Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -1,242 +0,0 @@
|
|||||||
# Agent field notes — scrabble-game
|
|
||||||
|
|
||||||
Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`,
|
|
||||||
`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working
|
|
||||||
memory, not a spec — **verify any named file / flag / function against the current code before acting
|
|
||||||
on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up.
|
|
||||||
|
|
||||||
## Codegen & build
|
|
||||||
|
|
||||||
- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may
|
|
||||||
reorder output. After running it, revert the churn on tables you did not touch and commit only your
|
|
||||||
table's change.
|
|
||||||
- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different
|
|
||||||
flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with
|
|
||||||
another flatc version.
|
|
||||||
- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust
|
|
||||||
`go build` / `go test`, not the editor squiggles.
|
|
||||||
- **go-jet type mapping:** SQL `numeric` → `float64`, `interval` → `string`. Never store money as
|
|
||||||
`numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as
|
|
||||||
**int seconds**, not `interval`.
|
|
||||||
- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a
|
|
||||||
bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a
|
|
||||||
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
|
|
||||||
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
|
|
||||||
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
|
|
||||||
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
|
|
||||||
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
|
|
||||||
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
|
|
||||||
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
|
|
||||||
|
|
||||||
## Native Android build (Capacitor)
|
|
||||||
|
|
||||||
The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
|
|
||||||
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:
|
|
||||||
|
|
||||||
- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
|
|
||||||
run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
|
|
||||||
`brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
|
|
||||||
non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
|
|
||||||
`/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
|
|
||||||
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
|
|
||||||
(`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
|
|
||||||
`android-36.1` does NOT satisfy compileSdk 36.
|
|
||||||
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
|
|
||||||
--cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
|
|
||||||
Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
|
|
||||||
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
|
|
||||||
ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
|
|
||||||
→ `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
|
|
||||||
(dodges the corepack flake).
|
|
||||||
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
|
|
||||||
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
|
|
||||||
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
|
|
||||||
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
|
|
||||||
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
|
|
||||||
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
|
|
||||||
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
|
|
||||||
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
|
|
||||||
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
|
|
||||||
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
|
|
||||||
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
|
|
||||||
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
|
|
||||||
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
|
|
||||||
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
|
|
||||||
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
|
|
||||||
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
|
|
||||||
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
|
|
||||||
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
|
|
||||||
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
|
|
||||||
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
|
|
||||||
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
|
|
||||||
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
|
|
||||||
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
|
|
||||||
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
|
|
||||||
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
|
|
||||||
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
|
|
||||||
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
|
|
||||||
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
|
|
||||||
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
|
|
||||||
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
|
|
||||||
|
|
||||||
## Wire / schema evolution (client ↔ gateway ↔ backend)
|
|
||||||
|
|
||||||
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
|
|
||||||
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. **But if chromium/webkit are
|
|
||||||
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
|
|
||||||
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
|
|
||||||
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
|
|
||||||
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
|
|
||||||
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
|
|
||||||
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
|
|
||||||
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
|
|
||||||
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
|
|
||||||
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
|
|
||||||
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
|
|
||||||
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
|
|
||||||
Use **testcontainers** for container-backed tests.
|
|
||||||
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
|
|
||||||
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).
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
|
|
||||||
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
|
|
||||||
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
|
|
||||||
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
|
|
||||||
#
|
|
||||||
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
|
|
||||||
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
|
|
||||||
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and
|
|
||||||
# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the
|
|
||||||
# runner host needs nothing pre-installed.
|
|
||||||
name: android-build
|
|
||||||
run-name: "android build ${{ github.sha }}"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
confirm:
|
|
||||||
description: 'Type "build" to confirm an APK build from master.'
|
|
||||||
required: true
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
NO_COLOR: "1"
|
|
||||||
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
|
|
||||||
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
|
|
||||||
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
|
||||||
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
|
|
||||||
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
|
|
||||||
VITE_PAYMENTS_DISABLED: "1"
|
|
||||||
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
|
|
||||||
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
|
|
||||||
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
|
|
||||||
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
|
|
||||||
# lives elsewhere (the default matches deploy/README.md's install path).
|
|
||||||
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
|
||||||
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
|
|
||||||
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
|
|
||||||
# missing — this doubles as the runner-access check (deploy/README.md).
|
|
||||||
- name: Verify the host Android SDK
|
|
||||||
run: |
|
|
||||||
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
|
||||||
if [ ! -x "$sm" ]; then
|
|
||||||
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
|
|
||||||
fi
|
|
||||||
for pkg in "platforms/android-36" "build-tools"; do
|
|
||||||
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
|
|
||||||
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
|
|
||||||
|
|
||||||
- name: Compute version + native build env
|
|
||||||
id: prep
|
|
||||||
env:
|
|
||||||
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
|
||||||
run: |
|
|
||||||
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
|
|
||||||
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
|
|
||||||
# rather than derive a versionCode from a "-N-gSHA" describe.
|
|
||||||
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
|
|
||||||
case "$desc" in
|
|
||||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
|
||||||
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
v="${desc#v}"
|
|
||||||
IFS=. read -r MA MI PA <<< "$v"
|
|
||||||
# 10# forces base-10 so a zero-padded part is never read as octal.
|
|
||||||
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
|
|
||||||
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
|
|
||||||
# trailing slash so the Connect endpoint never doubles it.
|
|
||||||
gateway="${PUBLIC_BASE_URL%/}"
|
|
||||||
{
|
|
||||||
echo "tag=$desc"
|
|
||||||
echo "name=$v"
|
|
||||||
echo "code=$code"
|
|
||||||
echo "gateway=$gateway"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
|
|
||||||
|
|
||||||
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
|
|
||||||
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
|
|
||||||
- name: Fetch dictionary DAWGs
|
|
||||||
run: |
|
|
||||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
|
||||||
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
ls -la "${GITHUB_WORKSPACE}/dawg"
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
run: npm install -g pnpm@11.0.9
|
|
||||||
|
|
||||||
- name: Install deps
|
|
||||||
working-directory: ui
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build the SPA (native flavour)
|
|
||||||
working-directory: ui
|
|
||||||
env:
|
|
||||||
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
|
|
||||||
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
|
|
||||||
run: pnpm run build
|
|
||||||
|
|
||||||
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
|
|
||||||
# (after the build, before cap sync copies dist/ into the native assets).
|
|
||||||
- name: Bundle the dictionaries
|
|
||||||
working-directory: ui
|
|
||||||
env:
|
|
||||||
DICT_DIR: ${{ github.workspace }}/dawg
|
|
||||||
run: node scripts/bundle-dicts.mjs
|
|
||||||
|
|
||||||
- name: Set up JDK 21
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
|
|
||||||
- name: Sync the native project
|
|
||||||
working-directory: ui
|
|
||||||
run: node_modules/.bin/cap sync android
|
|
||||||
|
|
||||||
- name: Decode the release keystore
|
|
||||||
id: keystore
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
|
||||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
|
|
||||||
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "keystore decoded -> signed release build"
|
|
||||||
else
|
|
||||||
echo "file=" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Assemble the release APK
|
|
||||||
working-directory: ui/android
|
|
||||||
env:
|
|
||||||
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
|
|
||||||
|
|
||||||
- name: Upload the APK artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: erudit-${{ steps.prep.outputs.name }}-apk
|
|
||||||
path: ui/android/app/build/outputs/apk/release/*.apk
|
|
||||||
if-no-files-found: error
|
|
||||||
@@ -156,11 +156,3 @@ The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` j
|
|||||||
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
|
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
|
|
||||||
|
|||||||
@@ -118,8 +118,6 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
|
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
|
||||||
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
|
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
|
||||||
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
|
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
|
||||||
| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | **Hard** tier of the client-version gate (ARCHITECTURE.md §2). Minimum client build the gateway will serve. Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind — the client then degrades to an offline "Update / Play offline" notice, not a hard lockout. Validated at load; a non-empty unparseable value fails startup. |
|
|
||||||
| `GATEWAY_RECOMMENDED_CLIENT_VERSION` | variable | _(empty)_ | **Soft** tier of the client-version gate (ARCHITECTURE.md §2). A build at or above `GATEWAY_MIN_CLIENT_VERSION` but **below** this is served normally, but every gated `Execute` response carries an additive `X-Update-Recommended: 1` header ⇒ the client shows a dismissable *update available* nudge (play continues). Empty ⇒ off. Validated at load: unparseable, or **below** `GATEWAY_MIN_CLIENT_VERSION`, fails startup. Bump it (ahead of `MIN`) to nudge upgrades before a hard cut-over. |
|
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||||
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
@@ -238,32 +236,6 @@ redeploy the matching old tag. This dump is a belt-and-braces net for a bad migr
|
|||||||
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
|
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
|
||||||
that survives losing the host.
|
that survives losing the host.
|
||||||
|
|
||||||
## Android app build & release (RuStore)
|
|
||||||
|
|
||||||
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
|
|
||||||
web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md).
|
|
||||||
|
|
||||||
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
|
|
||||||
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
|
|
||||||
but a clean `vMAJOR.MINOR.PATCH` and derives `versionCode = MA*1_000_000 + MI*1_000 + PA`,
|
|
||||||
`versionName = MA.MI.PA`. Watch it green with `python3 ~/.claude/bin/gitea-ci-watch.py`.
|
|
||||||
- **Output:** a `release APK` run artifact — **signed** when the signing secrets are present, an
|
|
||||||
**unsigned** release APK otherwise (a dry run still proves the pipeline).
|
|
||||||
- **Runner (host-executor):** JDK 21 comes from `setup-java`; the **Android SDK is host-provisioned** —
|
|
||||||
install it once (`sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0'`) and grant the
|
|
||||||
`runner` user read+exec (`sudo chmod -R a+rX /opt/android-sdk`). Override the path with the
|
|
||||||
`ANDROID_SDK_DIR` variable. The workflow's `Verify the host Android SDK` step fails fast with the fix if
|
|
||||||
the SDK is missing or unreadable.
|
|
||||||
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
|
|
||||||
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
|
|
||||||
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
|
||||||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
|
|
||||||
variable to the store listing once published (empty until then — the in-app update button no-ops).
|
|
||||||
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
|
|
||||||
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
|
|
||||||
away cleanly instead of failing blind.
|
|
||||||
- **Upload:** download the artifact and upload it to RuStore by hand (no automated RuStore-API upload in the MVP).
|
|
||||||
|
|
||||||
## Point-in-time recovery (PITR)
|
## Point-in-time recovery (PITR)
|
||||||
|
|
||||||
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
|
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
|
||||||
|
|||||||
@@ -100,24 +100,15 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
auth operations are unauthenticated and return the minted token. A unary
|
auth operations are unauthenticated and return the minted token. A unary
|
||||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||||
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
|
surface as Connect error codes. The client treats a connectivity edge failure as
|
||||||
via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
|
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
||||||
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
|
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
||||||
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
|
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
||||||
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
|
||||||
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
|
||||||
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
response was lost — its button is disabled while offline and the player re-issues it on
|
||||||
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
|
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
||||||
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
||||||
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
|
|
||||||
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
|
|
||||||
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
|
|
||||||
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
|
|
||||||
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
|
||||||
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
|
||||||
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
|
||||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online, never fed an offline
|
|
||||||
signal, so those channels never enter an offline state or show a local lobby.
|
|
||||||
**Edge hardening:** every request body on the public listener is capped at
|
**Edge hardening:** every request body on the public listener is capped at
|
||||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||||
@@ -149,40 +140,6 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
||||||
client's in-app stream while the app is open. Out-of-app delivery uses
|
client's in-app stream while the app is open. Out-of-app delivery uses
|
||||||
platform-native push via the platform side-service.
|
platform-native push via the platform side-service.
|
||||||
- **Client-version gate — two tiers** (native long-tail protection): a bundled native install (§13) can be
|
|
||||||
arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
|
|
||||||
version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
|
|
||||||
payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
|
|
||||||
because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
|
|
||||||
that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
|
|
||||||
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
|
|
||||||
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
|
|
||||||
vars empty ⇒ off, web behaviour unchanged).
|
|
||||||
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
|
|
||||||
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
|
|
||||||
the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
|
|
||||||
`offlineVersionLocked` (offline mode) and shows a **dismissable notice** — **"Update"** (native → the
|
|
||||||
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
|
|
||||||
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
|
|
||||||
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
|
|
||||||
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
|
|
||||||
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
|
|
||||||
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **≥ `min`**
|
|
||||||
(validated at config load); empty ⇒ the soft tier is off.
|
|
||||||
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
|
|
||||||
things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
|
|
||||||
reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
|
|
||||||
code — never changes; (3) the FBS schema stays **additive** (append trailing fields; `(deprecated)`, never
|
|
||||||
delete — deleting shifts field IDs and breaks older readers). A breaking wire change happens only *inside*
|
|
||||||
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
|
|
||||||
ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
|
|
||||||
rollout (see [`../deploy/README.md`](../deploy/README.md)).
|
|
||||||
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
|
|
||||||
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
|
|
||||||
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
|
|
||||||
**user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
|
|
||||||
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
|
|
||||||
rather than interrupting local play.
|
|
||||||
|
|
||||||
## 3. Authentication & sessions
|
## 3. Authentication & sessions
|
||||||
|
|
||||||
@@ -267,19 +224,6 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
|
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
|
||||||
Platform and email users are auto-provisioned **durable** accounts with an
|
Platform and email users are auto-provisioned **durable** accounts with an
|
||||||
identity.
|
identity.
|
||||||
- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play
|
|
||||||
identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated
|
|
||||||
id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first
|
|
||||||
launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local
|
|
||||||
games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest`
|
|
||||||
row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session
|
|
||||||
cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It
|
|
||||||
unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server
|
|
||||||
session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an
|
|
||||||
`update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2).
|
|
||||||
**Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity
|
|
||||||
transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the
|
|
||||||
prior online-session rule.
|
|
||||||
|
|
||||||
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
|
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
|
||||||
> two-bot model (one bot per service language, with `accounts.service_language`, a
|
> two-bot model (one bot per service language, with `accounts.service_language`, a
|
||||||
@@ -1388,16 +1332,13 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
|
|||||||
cache-first — the old build's version until a second load. The landing page and the conditional
|
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||||
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
||||||
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||||
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
|
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||||
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
|
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||||
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
|
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||||
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
|
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
||||||
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
|
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||||
invitations are hidden while offline. New Game's *with friends* carries an **online/offline segmented
|
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||||
control** — online = a friend invite, offline = **local pass-and-play (hotseat)** — with the online
|
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
||||||
segment disabled and offline forced when there is no network; a `vs_ai` or hotseat create is guarded when
|
|
||||||
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
|
|
||||||
the in-browser engine and driven by the same game screen, the robot replying locally.
|
|
||||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||||
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
|
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
|
||||||
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||||
@@ -1425,17 +1366,27 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
|
|||||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||||
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||||
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||||
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
|
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||||
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
|
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||||
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
|
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||||
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
|
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||||
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
|
mode** boots from the persisted session and
|
||||||
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
|
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||||
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
|
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||||
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
|
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||||
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
|
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
||||||
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
|
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
||||||
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
|
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
||||||
|
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
|
||||||
|
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
|
||||||
|
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
|
||||||
|
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
|
||||||
|
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
|
||||||
|
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
|
||||||
|
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
|
||||||
|
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
|
||||||
|
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
|
||||||
|
while offline. The gateway registers the `.webmanifest` MIME type
|
||||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||||
@@ -1517,24 +1468,6 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
|||||||
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
|
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
|
||||||
contour activates; the prod main host omits it.
|
contour activates; the prod main host omits it.
|
||||||
|
|
||||||
**Native Android build (Capacitor).** The SPA is also packaged as a standalone **Android app** (Capacitor 8,
|
|
||||||
appId `ru.eruditgame.app`, "Эрудит"; minSdk 24, compile/targetSdk 36, JDK 21), first for **RuStore**. It is
|
|
||||||
a **bundle** model — the WebView loads the packaged `dist/` from app assets (**no `server.url`**), so there
|
|
||||||
is no OTA: updates ship through the store, and the **client-version gate** (§2) turns away a build too old to
|
|
||||||
speak the current wire contract. Because the packaged origin is `file://`, the native build talks to an
|
|
||||||
**absolute** gateway origin (`VITE_GATEWAY_URL` = the production origin; `lib/origin.ts` centralises how absolute URLs are built), and the service worker is skipped (the bundle is
|
|
||||||
the cache). It is **offline-first**:
|
|
||||||
`ui/scripts/bundle-dicts.mjs` copies the versioned `scrabble-dictionary` release DAWGs into
|
|
||||||
`dist/dict/<variant>@<version>.dawg` (keyed on `VITE_DICT_VERSION`), so a cold first launch with no network
|
|
||||||
enters as a **local guest** (§3) and plays local vs_ai / hotseat from the bundled dictionaries. Purchases are
|
|
||||||
hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `workflow_dispatch` workflow
|
|
||||||
(`.gitea/workflows/android-build.yaml`, from `master`, `confirm=build`) that builds the native SPA, bundles
|
|
||||||
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
|
|
||||||
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
|
|
||||||
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
|
|
||||||
with a host-provisioned Android SDK; RuStore upload is manual. Full plan + runbook:
|
|
||||||
[`../ANDROID_PLAN.md`](../ANDROID_PLAN.md), [`../deploy/README.md`](../deploy/README.md).
|
|
||||||
|
|
||||||
## 14. CI & branches
|
## 14. CI & branches
|
||||||
|
|
||||||
- **Two long-lived branches**: **`development`** is the integration
|
- **Two long-lived branches**: **`development`** is the integration
|
||||||
|
|||||||
@@ -277,25 +277,22 @@ add-friend are off. AI games are **practice** — they never count toward a play
|
|||||||
statistics.
|
statistics.
|
||||||
|
|
||||||
### Offline mode
|
### Offline mode
|
||||||
The app plays **offline automatically** — there is **no on/off switch**. When it cannot reach the
|
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
|
||||||
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
|
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
|
||||||
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
|
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
||||||
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
|
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
||||||
app never touches the network.
|
A New Game against the robot creates a device-local **vs_ai** game — it
|
||||||
|
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
||||||
|
offline mode, and never sync to the account. So a game can be created and played with no connection,
|
||||||
|
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
|
||||||
|
(once installed) launches from a precached shell even with no network. Switching **to** offline first
|
||||||
|
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
|
||||||
|
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
|
||||||
|
(the download keeps running in the background, so a later switch is instant). The mode is
|
||||||
|
device-scoped and sticky across launches.
|
||||||
|
|
||||||
The **lobby stays unified**: your device-local games are active, while your server games are still
|
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
|
||||||
listed but **greyed out** — visible but un-openable until you are back online (a tap explains why).
|
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||||
Online-only surfaces (the Stats tab) are disabled and invitations are hidden while offline. A New Game
|
|
||||||
against the robot creates a device-local **vs_ai** game that plays entirely on the device with no
|
|
||||||
backend; local games are kept on the device and never sync to the account, so a game can be created and
|
|
||||||
played with no connection. The app quietly preloads the dictionaries for the player's enabled variants
|
|
||||||
while still online, and (once installed, or on the native app) launches from bundled/precached data even
|
|
||||||
with no network; if a variant's dictionary is not available offline, creating that game is disabled with
|
|
||||||
a short note.
|
|
||||||
|
|
||||||
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
|
|
||||||
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
|
|
||||||
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
|
||||||
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
||||||
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
|
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
|
||||||
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
|
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
|
||||||
@@ -308,31 +305,14 @@ pass-and-play game. A game that finishes normally is saved to the offline lobby
|
|||||||
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
|
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
|
||||||
so no one can wipe a game the moment they have moved.
|
so no one can wipe a game the moment they have moved.
|
||||||
|
|
||||||
Going offline and coming back are both **automatic**. On a cold launch with no connection the app
|
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
|
||||||
starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
|
with no connection it switches to offline for that session; when the device is online but the gateway
|
||||||
offline on its own, and restoring the network returns it online by itself. There is no dialog and no
|
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
||||||
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
|
||||||
offline, so you are never interrupted for a hiccup.
|
the network — airplane mode — switches to offline automatically, and restoring it returns online by
|
||||||
|
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
|
||||||
### Staying up to date
|
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
|
||||||
When a new client version is required, the app does not lock you out. On the **native** app a
|
dialog — is the player's choice: it is kept, and never undone automatically.
|
||||||
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
|
|
||||||
playing your on-device games); on the web it offers **Update** (reload) instead. A softer,
|
|
||||||
non-blocking **"update available"** banner appears in the lobby when a newer — but not yet
|
|
||||||
required — version exists; you can update or dismiss it, and play continues either way.
|
|
||||||
|
|
||||||
### Native app (Android)
|
|
||||||
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
|
|
||||||
client. Everything it needs is bundled, so it opens with **no network**: a first launch offline drops you
|
|
||||||
straight into the lobby as a **guest** (no sign-in wall) and you can play right away — against the robot, or
|
|
||||||
a **pass-and-play** game with friends on one device — using the dictionaries shipped inside the app. When the
|
|
||||||
network is available the app quietly establishes a guest in the background and online features (random
|
|
||||||
opponents, friends, statistics) light up without interrupting play; local games you started offline stay on
|
|
||||||
the device. To keep a durable account you sign in with **email** from Profile (the guest becomes your
|
|
||||||
account); Telegram and VK sign-in are not offered in the native app. In-app purchases are hidden in this
|
|
||||||
first release. Because an installed app can be far older than the server, a build that is **too old** to talk
|
|
||||||
to the current server shows a single, non-dismissable *update* screen whose button opens the store listing —
|
|
||||||
this appears only on an online action, never while you are playing offline.
|
|
||||||
|
|
||||||
### Social: friends, block, chat, nudge
|
### Social: friends, block, chat, nudge
|
||||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||||
|
|||||||
@@ -283,24 +283,22 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||||
|
|
||||||
### Офлайн-режим
|
### Офлайн-режим
|
||||||
Приложение играет **офлайн автоматически** — никакого переключателя нет. Когда оно не может достучаться
|
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||||
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
|
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||||
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
|
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||||
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
|
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||||
сети.
|
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||||
|
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||||
|
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||||
|
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||||
|
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||||
|
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||||
|
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||||
|
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||||
|
сохраняется между запусками.
|
||||||
|
|
||||||
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
|
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||||
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
|
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||||
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
|
|
||||||
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
|
|
||||||
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
|
|
||||||
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
|
|
||||||
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
|
|
||||||
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
|
|
||||||
|
|
||||||
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
|
|
||||||
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди. В
|
|
||||||
игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
|
||||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||||
@@ -314,32 +312,14 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||||
своего хода.
|
своего хода.
|
||||||
|
|
||||||
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
|
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||||
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
|
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||||
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
|
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
||||||
вас не прерывают из-за короткой икоты.
|
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
||||||
|
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
||||||
### Актуальная версия
|
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
|
||||||
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
|
|
||||||
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
|
|
||||||
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
|
|
||||||
|
|
||||||
### Нативное приложение (Android)
|
|
||||||
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
|
|
||||||
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
|
|
||||||
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
|
|
||||||
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
|
|
||||||
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
|
|
||||||
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
|
|
||||||
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
|
|
||||||
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
|
|
||||||
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
|
|
||||||
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
|
|
||||||
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
|
|
||||||
офлайн-игры.
|
|
||||||
|
|
||||||
### Социальное: друзья, блок, чат, nudge
|
### Социальное: друзья, блок, чат, nudge
|
||||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||||
|
|||||||
@@ -17,43 +17,21 @@ tests or touching CI.
|
|||||||
- **UI** — Vitest (unit) + Playwright
|
- **UI** — Vitest (unit) + Playwright
|
||||||
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
||||||
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||||
derivation, the GCG share/copy/download choice and the **net-state reducer**
|
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
||||||
(`netstate.test.ts` — every transition of the connectivity/version machine, the anti-flap
|
|
||||||
hysteresis, the two-tier version decision and all 12 offline edge cases as pure assertions),
|
|
||||||
plus Playwright specs against the
|
|
||||||
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
||||||
invitations section, the stats screen, profile editing, and the export chooser's
|
invitations section, the stats screen, profile editing, and the export chooser's
|
||||||
finished-only visibility + its signed-URL download flow (route-intercepted). The
|
finished-only visibility + its signed-URL download flow (route-intercepted). The
|
||||||
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
|
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
|
||||||
end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
|
end-to-end: it forces the installed-PWA display mode, enters offline through the
|
||||||
auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
|
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
|
||||||
Stats tab), self-heals back online, then creates and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
||||||
rack is deterministic and the human can tap out a precomputed opening), asserting the
|
rack is deterministic and the human can tap out a precomputed opening), asserting the
|
||||||
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
|
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
|
||||||
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
|
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
|
||||||
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
|
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
|
||||||
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
|
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
|
||||||
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
|
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
|
||||||
production build. The **native offline-first** spec (`e2e/native.spec.ts`) injects
|
production build.
|
||||||
`window.androidBridge` (so `@capacitor/core` resolves the platform to `android` — a bare
|
|
||||||
`Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands
|
|
||||||
in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier
|
|
||||||
(`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
|
|
||||||
`window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
|
|
||||||
**unified lobby** (closing G-step-0) — and Profile hides the Telegram/VK link buttons.
|
|
||||||
The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
|
|
||||||
hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
|
|
||||||
dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
|
|
||||||
update_required` mapping.
|
|
||||||
- **Client-version gate** (Go, two tiers) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH`
|
|
||||||
parse (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering. `connectsrv`'s server tests
|
|
||||||
assert the **hard** tier — a too-old `Execute` returns `result_code = "update_required"` (and the op
|
|
||||||
handler never ran), a too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min /
|
|
||||||
unparseable header / equal version all pass (fail-open) — and the **soft** tier (`TestExecuteUpdateRecommended`):
|
|
||||||
a served build in `min ≤ v < recommended` carries the `X-Update-Recommended: 1` response header, while a
|
|
||||||
too-old, up-to-date, absent/garbled, or dormant-tier request does not. `config` tests reject a non-empty
|
|
||||||
unparseable `GATEWAY_MIN_CLIENT_VERSION` / `GATEWAY_RECOMMENDED_CLIENT_VERSION`, and a recommended below the
|
|
||||||
minimum.
|
|
||||||
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
||||||
real self-played 35-move game) must rasterize to a plausible PNG
|
real self-played 35-move game) must rasterize to a plausible PNG
|
||||||
@@ -197,16 +175,6 @@ tests or touching CI.
|
|||||||
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
|
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
|
||||||
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
|
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
|
||||||
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
|
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
|
||||||
- **Native Android (manual on-device smoke)** — the packaged APK is verified by hand on a device /
|
|
||||||
emulator before release (the automated e2e covers the offline-first logic; WebView-specific chrome and
|
|
||||||
store behaviour are not reproducible in Playwright). Checklist: installs and cold-launches; in
|
|
||||||
**airplane mode** the cold launch lands in the **guest lobby**; play a local **vs_ai** move and a
|
|
||||||
**2-player hotseat** game with no network; the hardware **Back** button navigates then exits at the
|
|
||||||
root; a share / export link resolves to `erudit-game.ru` (not `file://`); turning the **network on**
|
|
||||||
lights up online play (a server guest is established); Profile offers **email** sign-in but no
|
|
||||||
Telegram / VK link; and with `GATEWAY_MIN_CLIENT_VERSION` bumped above the build, an online action
|
|
||||||
raises the terminal **update** overlay. Measure native chrome (safe-area / edge-to-edge) by CDP, not by
|
|
||||||
eyeballing a screenshot — an emulator WebView may be newer than a user's device (see `.claude/CLAUDE.md`).
|
|
||||||
|
|
||||||
## Principles
|
## Principles
|
||||||
|
|
||||||
|
|||||||
@@ -221,24 +221,22 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
}
|
}
|
||||||
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Backend: backend,
|
Backend: backend,
|
||||||
Limiter: limiter,
|
Limiter: limiter,
|
||||||
Tracker: tracker,
|
Tracker: tracker,
|
||||||
Banlist: banlist,
|
Banlist: banlist,
|
||||||
Blocklist: blocklist,
|
Blocklist: blocklist,
|
||||||
Honeytoken: cfg.Abuse.Honeytoken,
|
Honeytoken: cfg.Abuse.Honeytoken,
|
||||||
VKAppSecret: cfg.VKAppSecret,
|
VKAppSecret: cfg.VKAppSecret,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
RateLimit: cfg.RateLimit,
|
RateLimit: cfg.RateLimit,
|
||||||
Heartbeat: cfg.PushHeartbeatInterval,
|
Heartbeat: cfg.PushHeartbeatInterval,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
AdminProxy: adminProxy,
|
AdminProxy: adminProxy,
|
||||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
||||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||||
MinClientVersion: cfg.MinClientVersion,
|
|
||||||
RecommendedClientVersion: cfg.RecommendedClientVersion,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
// Package clientver parses and compares the leading MAJOR.MINOR.PATCH of a client
|
|
||||||
// version string so the edge can turn away a build too old to speak the current wire
|
|
||||||
// contract. It is deliberately dependency-free and tolerant: the version rides an HTTP
|
|
||||||
// header (X-Client-Version) that a build stamps from `git describe --tags`, so any
|
|
||||||
// `-N-gSHA` or `+meta` suffix is ignored, and anything unparseable is reported as such
|
|
||||||
// (the caller fails open — an absent or garbled header is never treated as too old).
|
|
||||||
package clientver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Version is a parsed semantic version triple. Only MAJOR.MINOR.PATCH participate in the
|
|
||||||
// ordering; any pre-release or build suffix is dropped at parse time.
|
|
||||||
type Version struct {
|
|
||||||
Major, Minor, Patch int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse extracts the leading MAJOR.MINOR.PATCH from s, tolerating an optional leading
|
|
||||||
// "v" and any `-N-gSHA` or `+meta` suffix (as produced by `git describe --tags`). It
|
|
||||||
// reports ok=false when s has fewer than three numeric components or any component is not
|
|
||||||
// an integer, so the caller can distinguish a real version from a dev/empty string.
|
|
||||||
func Parse(s string) (Version, bool) {
|
|
||||||
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
|
||||||
if i := strings.IndexAny(s, "-+"); i >= 0 {
|
|
||||||
s = s[:i]
|
|
||||||
}
|
|
||||||
p := strings.SplitN(s, ".", 4)
|
|
||||||
if len(p) < 3 {
|
|
||||||
return Version{}, false
|
|
||||||
}
|
|
||||||
var v Version
|
|
||||||
var err error
|
|
||||||
if v.Major, err = strconv.Atoi(p[0]); err != nil {
|
|
||||||
return Version{}, false
|
|
||||||
}
|
|
||||||
if v.Minor, err = strconv.Atoi(p[1]); err != nil {
|
|
||||||
return Version{}, false
|
|
||||||
}
|
|
||||||
if v.Patch, err = strconv.Atoi(p[2]); err != nil {
|
|
||||||
return Version{}, false
|
|
||||||
}
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Less reports whether a orders before b by MAJOR, then MINOR, then PATCH. Equal versions
|
|
||||||
// are not Less than each other, so a client exactly at the minimum passes the gate.
|
|
||||||
func Less(a, b Version) bool {
|
|
||||||
if a.Major != b.Major {
|
|
||||||
return a.Major < b.Major
|
|
||||||
}
|
|
||||||
if a.Minor != b.Minor {
|
|
||||||
return a.Minor < b.Minor
|
|
||||||
}
|
|
||||||
return a.Patch < b.Patch
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package clientver
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// TestParse covers the version strings the edge actually sees: a plain and a "v"-prefixed
|
|
||||||
// triple, a `git describe --tags` suffix, build metadata, surrounding space, and the
|
|
||||||
// non-version strings (dev/empty/too-few/non-numeric) that must report ok=false so the
|
|
||||||
// gate fails open.
|
|
||||||
func TestParse(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
in string
|
|
||||||
want Version
|
|
||||||
wantK bool
|
|
||||||
}{
|
|
||||||
{"plain", "1.16.0", Version{1, 16, 0}, true},
|
|
||||||
{"v-prefixed", "v1.16.0", Version{1, 16, 0}, true},
|
|
||||||
{"git describe suffix", "v1.16.0-3-gabc1234", Version{1, 16, 0}, true},
|
|
||||||
{"build metadata", "1.16.0+ci42", Version{1, 16, 0}, true},
|
|
||||||
{"surrounding space", " v2.0.1 ", Version{2, 0, 1}, true},
|
|
||||||
{"extra component ignored", "v1.16.0.4", Version{1, 16, 0}, true},
|
|
||||||
{"dev", "dev", Version{}, false},
|
|
||||||
{"empty", "", Version{}, false},
|
|
||||||
{"too few components", "1.16", Version{}, false},
|
|
||||||
{"non-numeric patch", "1.16.x", Version{}, false},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got, ok := Parse(tc.in)
|
|
||||||
if ok != tc.wantK {
|
|
||||||
t.Fatalf("Parse(%q) ok = %v, want %v", tc.in, ok, tc.wantK)
|
|
||||||
}
|
|
||||||
if ok && got != tc.want {
|
|
||||||
t.Errorf("Parse(%q) = %+v, want %+v", tc.in, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLess covers the ordering by MAJOR, then MINOR, then PATCH, and that an equal version
|
|
||||||
// is not Less (a client exactly at the minimum passes the gate).
|
|
||||||
func TestLess(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
a, b Version
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"equal", Version{1, 16, 0}, Version{1, 16, 0}, false},
|
|
||||||
{"major less", Version{1, 9, 9}, Version{2, 0, 0}, true},
|
|
||||||
{"major greater", Version{2, 0, 0}, Version{1, 9, 9}, false},
|
|
||||||
{"minor less", Version{1, 16, 5}, Version{1, 17, 0}, true},
|
|
||||||
{"minor greater", Version{1, 17, 0}, Version{1, 16, 9}, false},
|
|
||||||
{"patch less", Version{1, 16, 0}, Version{1, 16, 1}, true},
|
|
||||||
{"patch greater", Version{1, 16, 2}, Version{1, 16, 1}, false},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := Less(tc.a, tc.b); got != tc.want {
|
|
||||||
t.Errorf("Less(%+v, %+v) = %v, want %v", tc.a, tc.b, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"scrabble/gateway/internal/clientver"
|
|
||||||
pkgtel "scrabble/pkg/telemetry"
|
pkgtel "scrabble/pkg/telemetry"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,16 +54,6 @@ type Config struct {
|
|||||||
// MaxBodyBytes caps one inbound request body on the public listener and one
|
// MaxBodyBytes caps one inbound request body on the public listener and one
|
||||||
// Connect message read; oversized requests are refused without buffering.
|
// Connect message read; oversized requests are refused without buffering.
|
||||||
MaxBodyBytes int
|
MaxBodyBytes int
|
||||||
// MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
|
|
||||||
// the edge serves. A client reporting an older X-Client-Version is turned away with an
|
|
||||||
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
|
|
||||||
// (every client is served) — the default for web-only deployments.
|
|
||||||
MinClientVersion string
|
|
||||||
// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
|
|
||||||
// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
|
|
||||||
// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
|
|
||||||
// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
|
|
||||||
RecommendedClientVersion string
|
|
||||||
// RateLimit configures the in-memory anti-abuse limiter.
|
// RateLimit configures the in-memory anti-abuse limiter.
|
||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
||||||
@@ -237,16 +226,14 @@ func (c VKIDConfig) Enabled() bool {
|
|||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
var err error
|
var err error
|
||||||
c := Config{
|
c := Config{
|
||||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||||
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
|
|
||||||
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
|
|
||||||
VKID: VKIDConfig{
|
VKID: VKIDConfig{
|
||||||
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
||||||
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
||||||
@@ -345,23 +332,6 @@ func (c Config) validate() error {
|
|||||||
if c.MaxBodyBytes <= 0 {
|
if c.MaxBodyBytes <= 0 {
|
||||||
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
|
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
|
||||||
}
|
}
|
||||||
if c.MinClientVersion != "" {
|
|
||||||
if _, ok := clientver.Parse(c.MinClientVersion); !ok {
|
|
||||||
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.RecommendedClientVersion != "" {
|
|
||||||
rec, ok := clientver.Parse(c.RecommendedClientVersion)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
|
|
||||||
}
|
|
||||||
// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
|
|
||||||
// misconfiguration (every client below min is already turned away). An empty/unparseable
|
|
||||||
// minimum imposes no lower bound, so the recommended may stand alone.
|
|
||||||
if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
|
|
||||||
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
|
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
|
||||||
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,65 +47,6 @@ func TestLoadMaxBodyBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLoadMinClientVersion verifies the client-version gate config: dormant (empty) by
|
|
||||||
// default, a parseable version accepted, and an unparseable one rejected.
|
|
||||||
func TestLoadMinClientVersion(t *testing.T) {
|
|
||||||
c, err := Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load: %v", err)
|
|
||||||
}
|
|
||||||
if c.MinClientVersion != "" {
|
|
||||||
t.Errorf("MinClientVersion = %q, want empty (gate dormant)", c.MinClientVersion)
|
|
||||||
}
|
|
||||||
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
|
|
||||||
if c, err = Load(); err != nil {
|
|
||||||
t.Fatalf("Load with a valid min version: %v", err)
|
|
||||||
}
|
|
||||||
if c.MinClientVersion != "v1.16.0" {
|
|
||||||
t.Errorf("MinClientVersion = %q, want %q", c.MinClientVersion, "v1.16.0")
|
|
||||||
}
|
|
||||||
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "dev")
|
|
||||||
if _, err := Load(); err == nil {
|
|
||||||
t.Fatal("Load: expected an error for an unparseable GATEWAY_MIN_CLIENT_VERSION, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoadRecommendedClientVersion verifies the soft-tier config: dormant (empty) by default, a
|
|
||||||
// parseable version accepted (standing alone with no minimum, or at/above one), an unparseable one
|
|
||||||
// rejected, and a recommended below the minimum rejected.
|
|
||||||
func TestLoadRecommendedClientVersion(t *testing.T) {
|
|
||||||
c, err := Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load: %v", err)
|
|
||||||
}
|
|
||||||
if c.RecommendedClientVersion != "" {
|
|
||||||
t.Errorf("RecommendedClientVersion = %q, want empty (soft tier dormant)", c.RecommendedClientVersion)
|
|
||||||
}
|
|
||||||
// Stands alone with no minimum configured.
|
|
||||||
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.20.0")
|
|
||||||
if c, err = Load(); err != nil {
|
|
||||||
t.Fatalf("Load with a valid recommended version (no min): %v", err)
|
|
||||||
}
|
|
||||||
if c.RecommendedClientVersion != "v1.20.0" {
|
|
||||||
t.Errorf("RecommendedClientVersion = %q, want %q", c.RecommendedClientVersion, "v1.20.0")
|
|
||||||
}
|
|
||||||
// At or above the minimum is accepted.
|
|
||||||
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
|
|
||||||
if _, err = Load(); err != nil {
|
|
||||||
t.Fatalf("Load with recommended >= min: %v", err)
|
|
||||||
}
|
|
||||||
// Below the minimum is rejected.
|
|
||||||
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.10.0")
|
|
||||||
if _, err := Load(); err == nil {
|
|
||||||
t.Fatal("Load: expected an error for a recommended version below the minimum, got nil")
|
|
||||||
}
|
|
||||||
// Unparseable is rejected.
|
|
||||||
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "dev")
|
|
||||||
if _, err := Load(); err == nil {
|
|
||||||
t.Fatal("Load: expected an error for an unparseable GATEWAY_RECOMMENDED_CLIENT_VERSION, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
|
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
|
||||||
// the agreed thresholds, and no honeytoken.
|
// the agreed thresholds, and no honeytoken.
|
||||||
func TestLoadAbuseDefaults(t *testing.T) {
|
func TestLoadAbuseDefaults(t *testing.T) {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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,7 +26,6 @@ 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"
|
||||||
@@ -47,21 +46,6 @@ const heartbeatKind = "heartbeat"
|
|||||||
// spoofer, so trusting it is safe.
|
// spoofer, so trusting it is safe.
|
||||||
const honeypotHeader = "X-Scrabble-Honeypot"
|
const honeypotHeader = "X-Scrabble-Honeypot"
|
||||||
|
|
||||||
// clientVersionHeader carries the client build version (stamped from `git describe --tags`) so
|
|
||||||
// the edge can turn away a build too old to speak the current wire contract, before it decodes
|
|
||||||
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
|
|
||||||
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
|
|
||||||
// "you must update". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
|
|
||||||
// on a served Execute response when the client is at or above the hard minimum but below the
|
|
||||||
// recommended version, it nudges an update without failing the call. Headers are the
|
|
||||||
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
|
|
||||||
// contract (docs/ARCHITECTURE.md §2).
|
|
||||||
const (
|
|
||||||
clientVersionHeader = "X-Client-Version"
|
|
||||||
resultUpdateRequired = "update_required"
|
|
||||||
updateRecommendedHeader = "X-Update-Recommended"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||||
// class field of the periodic rejection report.
|
// class field of the periodic rejection report.
|
||||||
const (
|
const (
|
||||||
@@ -104,17 +88,6 @@ type Server struct {
|
|||||||
|
|
||||||
maxBodyBytes int
|
maxBodyBytes int
|
||||||
|
|
||||||
// minClient is the lowest client version served; gateOn is false when the gate is dormant
|
|
||||||
// (GATEWAY_MIN_CLIENT_VERSION empty or unparseable).
|
|
||||||
minClient clientver.Version
|
|
||||||
gateOn bool
|
|
||||||
|
|
||||||
// recClient is the version below which a served client is nudged to update (the soft tier);
|
|
||||||
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
|
|
||||||
// unparseable). It is independent of the hard gate.
|
|
||||||
recClient clientver.Version
|
|
||||||
recOn bool
|
|
||||||
|
|
||||||
publicPolicy ratelimit.Policy
|
publicPolicy ratelimit.Policy
|
||||||
userPolicy ratelimit.Policy
|
userPolicy ratelimit.Policy
|
||||||
emailPolicy ratelimit.Policy
|
emailPolicy ratelimit.Policy
|
||||||
@@ -155,13 +128,6 @@ type Deps struct {
|
|||||||
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
||||||
// zero or negative selects config.DefaultMaxBodyBytes.
|
// zero or negative selects config.DefaultMaxBodyBytes.
|
||||||
MaxBodyBytes int
|
MaxBodyBytes int
|
||||||
// MinClientVersion is the lowest client version (MAJOR.MINOR.PATCH) the edge serves; an
|
|
||||||
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
|
|
||||||
// the gate dormant.
|
|
||||||
MinClientVersion string
|
|
||||||
// RecommendedClientVersion is the version below which a served client is nudged to update (the
|
|
||||||
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
|
|
||||||
RecommendedClientVersion string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs the edge service.
|
// NewServer constructs the edge service.
|
||||||
@@ -194,30 +160,6 @@ func NewServer(d Deps) *Server {
|
|||||||
if rl == (config.RateLimitConfig{}) {
|
if rl == (config.RateLimitConfig{}) {
|
||||||
rl = config.DefaultRateLimit()
|
rl = config.DefaultRateLimit()
|
||||||
}
|
}
|
||||||
// Parse the minimum client version once. Config.validate already rejects an unparseable
|
|
||||||
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
|
|
||||||
// the gate dormant.
|
|
||||||
var minClient clientver.Version
|
|
||||||
gateOn := false
|
|
||||||
if d.MinClientVersion != "" {
|
|
||||||
if v, ok := clientver.Parse(d.MinClientVersion); ok {
|
|
||||||
minClient, gateOn = v, true
|
|
||||||
} else {
|
|
||||||
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
|
|
||||||
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
|
|
||||||
// construction; an empty value leaves the soft tier dormant.
|
|
||||||
var recClient clientver.Version
|
|
||||||
recOn := false
|
|
||||||
if d.RecommendedClientVersion != "" {
|
|
||||||
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
|
|
||||||
recClient, recOn = v, true
|
|
||||||
} else {
|
|
||||||
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &Server{
|
return &Server{
|
||||||
registry: d.Registry,
|
registry: d.Registry,
|
||||||
sessions: d.Sessions,
|
sessions: d.Sessions,
|
||||||
@@ -234,10 +176,6 @@ func NewServer(d Deps) *Server {
|
|||||||
adminProxy: d.AdminProxy,
|
adminProxy: d.AdminProxy,
|
||||||
metrics: newServerMetrics(d.Meter, blocklist),
|
metrics: newServerMetrics(d.Meter, blocklist),
|
||||||
maxBodyBytes: maxBody,
|
maxBodyBytes: maxBody,
|
||||||
minClient: minClient,
|
|
||||||
gateOn: gateOn,
|
|
||||||
recClient: recClient,
|
|
||||||
recOn: recOn,
|
|
||||||
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
||||||
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
||||||
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
|
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
|
||||||
@@ -347,71 +285,15 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientTooOld reports whether the X-Client-Version header names a version below the configured
|
|
||||||
// minimum. It fails open: with the gate dormant, or an absent or unparseable header, it returns
|
|
||||||
// false. The header is a client-controlled compatibility signal, not an access control (it is
|
|
||||||
// trivially spoofable), so a missing or garbled value — an old build predating the header, or a
|
|
||||||
// non-browser caller — must never be blocked spuriously.
|
|
||||||
func (s *Server) clientTooOld(header string) bool {
|
|
||||||
if !s.gateOn {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
v, ok := clientver.Parse(header)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return clientver.Less(v, s.minClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
|
|
||||||
// band — at or above the hard minimum but below the recommended version — so a served response can
|
|
||||||
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
|
|
||||||
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
|
|
||||||
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
|
|
||||||
// leaves minClient at the zero version, which no real version is below, so the band is simply
|
|
||||||
// "below recommended").
|
|
||||||
func (s *Server) clientUpdateRecommended(header string) bool {
|
|
||||||
if !s.recOn {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
v, ok := clientver.Parse(header)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute runs one unary operation. Domain failures are returned in the envelope
|
// Execute runs one unary operation. Domain failures are returned in the envelope
|
||||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||||
// session, unknown type, internal) become Connect errors.
|
// session, unknown type, internal) become Connect errors.
|
||||||
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
|
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
msgType := req.Msg.GetMessageType()
|
msgType := req.Msg.GetMessageType()
|
||||||
result := "internal"
|
result := "internal"
|
||||||
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
|
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
|
||||||
|
|
||||||
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
|
|
||||||
// client at or above the hard minimum but below the recommended version is told an update is
|
|
||||||
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
|
|
||||||
// version yields no nudge.
|
|
||||||
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
|
|
||||||
defer func() {
|
|
||||||
if recommend && resp != nil {
|
|
||||||
resp.Header().Set(updateRecommendedHeader, "1")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
|
|
||||||
// the payload is decoded, so a too-old client makes zero successful calls but sees the
|
|
||||||
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
|
|
||||||
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
|
|
||||||
result = resultUpdateRequired
|
|
||||||
return connect.NewResponse(&edgev1.ExecuteResponse{
|
|
||||||
RequestId: req.Msg.GetRequestId(),
|
|
||||||
ResultCode: resultUpdateRequired,
|
|
||||||
}), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
op, ok := s.registry.Lookup(msgType)
|
op, ok := s.registry.Lookup(msgType)
|
||||||
if !ok {
|
if !ok {
|
||||||
result = "unknown_type"
|
result = "unknown_type"
|
||||||
@@ -481,11 +363,6 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
// Subscribe streams the authenticated user's live events with a keep-alive
|
// 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,13 +22,8 @@ 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. The client-version gate is off.
|
// client plus a cleanup func.
|
||||||
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)
|
||||||
@@ -36,40 +31,12 @@ func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc
|
|||||||
t.Fatalf("backendclient: %v", err)
|
t.Fatalf("backendclient: %v", err)
|
||||||
}
|
}
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: transcode.NewRegistry(backend, nil),
|
Registry: transcode.NewRegistry(backend, nil),
|
||||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||||
Limiter: ratelimit.New(),
|
Limiter: ratelimit.New(),
|
||||||
Hub: push.NewHub(0),
|
Hub: push.NewHub(0),
|
||||||
RateLimit: config.DefaultRateLimit(),
|
RateLimit: config.DefaultRateLimit(),
|
||||||
Heartbeat: 15 * time.Second,
|
Heartbeat: 15 * time.Second,
|
||||||
MinClientVersion: minVersion,
|
|
||||||
})
|
|
||||||
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
|
||||||
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
|
||||||
return client, func() {
|
|
||||||
edgeSrv.Close()
|
|
||||||
_ = backend.Close()
|
|
||||||
backendSrv.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
|
|
||||||
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
|
||||||
t.Helper()
|
|
||||||
backendSrv := httptest.NewServer(backendHandler)
|
|
||||||
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("backendclient: %v", err)
|
|
||||||
}
|
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
|
||||||
Registry: transcode.NewRegistry(backend, nil),
|
|
||||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
|
||||||
Limiter: ratelimit.New(),
|
|
||||||
Hub: push.NewHub(0),
|
|
||||||
RateLimit: config.DefaultRateLimit(),
|
|
||||||
Heartbeat: 15 * time.Second,
|
|
||||||
MinClientVersion: minVersion,
|
|
||||||
RecommendedClientVersion: recommendedVersion,
|
|
||||||
})
|
})
|
||||||
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
||||||
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
||||||
@@ -141,132 +108,6 @@ func TestExecuteGuestGate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestExecuteVersionGate verifies the client-version gate on the unary path: a build older
|
|
||||||
// than the configured minimum is turned away with the update_required result code before the
|
|
||||||
// registry lookup (an unknown message type is gated too, proving the short-circuit), while an
|
|
||||||
// equal, newer, absent, or unparseable version passes; and the gate is dormant when unset.
|
|
||||||
func TestExecuteVersionGate(t *testing.T) {
|
|
||||||
// The backend answers auth.guest with a session; a gated call never reaches it.
|
|
||||||
backend := func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
min string
|
|
||||||
header string
|
|
||||||
msgType string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"too old is gated before lookup", "v1.16.0", "v1.0.0", "bogus.unknown", "update_required"},
|
|
||||||
{"equal passes", "v1.16.0", "v1.16.0", transcode.MsgAuthGuest, "ok"},
|
|
||||||
{"newer passes", "v1.16.0", "v2.0.0", transcode.MsgAuthGuest, "ok"},
|
|
||||||
{"absent header fails open", "v1.16.0", "", transcode.MsgAuthGuest, "ok"},
|
|
||||||
{"unparseable header fails open", "v1.16.0", "dev", transcode.MsgAuthGuest, "ok"},
|
|
||||||
{"gate dormant when unset", "", "v1.0.0", transcode.MsgAuthGuest, "ok"},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
client, cleanup := newEdgeMin(t, tc.min, backend)
|
|
||||||
defer cleanup()
|
|
||||||
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: tc.msgType, RequestId: "rq"})
|
|
||||||
if tc.header != "" {
|
|
||||||
req.Header().Set("X-Client-Version", tc.header)
|
|
||||||
}
|
|
||||||
resp, err := client.Execute(context.Background(), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("execute: %v", err)
|
|
||||||
}
|
|
||||||
if got := resp.Msg.GetResultCode(); got != tc.want {
|
|
||||||
t.Fatalf("result_code = %q, want %q", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
|
|
||||||
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
|
|
||||||
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
|
|
||||||
// and a dormant soft tier get no header.
|
|
||||||
func TestExecuteUpdateRecommended(t *testing.T) {
|
|
||||||
backend := func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
min string
|
|
||||||
rec string
|
|
||||||
header string
|
|
||||||
wantResult string
|
|
||||||
wantHeader bool
|
|
||||||
}{
|
|
||||||
{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
|
|
||||||
{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
|
|
||||||
{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
|
|
||||||
{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
|
|
||||||
{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
|
|
||||||
{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
|
|
||||||
{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
|
|
||||||
{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
|
|
||||||
{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
|
|
||||||
defer cleanup()
|
|
||||||
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
|
|
||||||
if tc.header != "" {
|
|
||||||
req.Header().Set("X-Client-Version", tc.header)
|
|
||||||
}
|
|
||||||
resp, err := client.Execute(context.Background(), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("execute: %v", err)
|
|
||||||
}
|
|
||||||
if got := resp.Msg.GetResultCode(); got != tc.wantResult {
|
|
||||||
t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
|
|
||||||
}
|
|
||||||
if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
|
|
||||||
t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
|
|
||||||
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
|
|
||||||
// fails Unauthenticated with no session, proving it passed the version gate).
|
|
||||||
func TestSubscribeVersionGate(t *testing.T) {
|
|
||||||
noBackend := func(_ http.ResponseWriter, _ *http.Request) {}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
min string
|
|
||||||
header string
|
|
||||||
want connect.Code
|
|
||||||
}{
|
|
||||||
{"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition},
|
|
||||||
{"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated},
|
|
||||||
{"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
client, cleanup := newEdgeMin(t, tc.min, noBackend)
|
|
||||||
defer cleanup()
|
|
||||||
req := connect.NewRequest(&edgev1.SubscribeRequest{})
|
|
||||||
if tc.header != "" {
|
|
||||||
req.Header().Set("X-Client-Version", tc.header)
|
|
||||||
}
|
|
||||||
stream, err := client.Subscribe(context.Background(), req)
|
|
||||||
if err == nil {
|
|
||||||
for stream.Receive() {
|
|
||||||
}
|
|
||||||
err = stream.Err()
|
|
||||||
}
|
|
||||||
if got := connect.CodeOf(err); got != tc.want {
|
|
||||||
t.Fatalf("code = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteAuthedRequiresSession(t *testing.T) {
|
func TestExecuteAuthedRequiresSession(t *testing.T) {
|
||||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Error("backend must not be called without a session")
|
t.Error("backend must not be called without a session")
|
||||||
|
|||||||
@@ -31,11 +31,6 @@ enables the "Link Telegram" web sign-in (the Login Widget) — inert until the s
|
|||||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||||
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
|
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
|
||||||
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
||||||
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
|
|
||||||
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
|
|
||||||
in the RuStore MVP), `VITE_RUSTORE_URL` (the update overlay's store target; empty until published) and
|
|
||||||
`VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by
|
|
||||||
`.gitea/workflows/android-build.yaml`; see `ANDROID_PLAN.md`.
|
|
||||||
|
|
||||||
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||||
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
|
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
|
||||||
|
|
||||||
# Built application files
|
|
||||||
*.apk
|
|
||||||
*.aar
|
|
||||||
*.ap_
|
|
||||||
*.aab
|
|
||||||
|
|
||||||
# Files for the ART/Dalvik VM
|
|
||||||
*.dex
|
|
||||||
|
|
||||||
# Java class files
|
|
||||||
*.class
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
bin/
|
|
||||||
gen/
|
|
||||||
out/
|
|
||||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
|
||||||
# release/
|
|
||||||
|
|
||||||
# Gradle files
|
|
||||||
.gradle/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
|
||||||
local.properties
|
|
||||||
|
|
||||||
# Proguard folder generated by Eclipse
|
|
||||||
proguard/
|
|
||||||
|
|
||||||
# Log Files
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Android Studio Navigation editor temp files
|
|
||||||
.navigation/
|
|
||||||
|
|
||||||
# Android Studio captures folder
|
|
||||||
captures/
|
|
||||||
|
|
||||||
# IntelliJ
|
|
||||||
*.iml
|
|
||||||
.idea/workspace.xml
|
|
||||||
.idea/tasks.xml
|
|
||||||
.idea/gradle.xml
|
|
||||||
.idea/assetWizardSettings.xml
|
|
||||||
.idea/dictionaries
|
|
||||||
.idea/libraries
|
|
||||||
# Android Studio 3 in .gitignore file.
|
|
||||||
.idea/caches
|
|
||||||
.idea/modules.xml
|
|
||||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
|
||||||
.idea/navEditor.xml
|
|
||||||
|
|
||||||
# Keystore files
|
|
||||||
# Release signing material must never be committed (see ANDROID_PLAN.md — keystore loss/leak risk).
|
|
||||||
*.jks
|
|
||||||
*.keystore
|
|
||||||
|
|
||||||
# External native build folder generated in Android Studio 2.2 and later
|
|
||||||
.externalNativeBuild
|
|
||||||
.cxx/
|
|
||||||
|
|
||||||
# Google Services (e.g. APIs or Firebase)
|
|
||||||
# google-services.json
|
|
||||||
|
|
||||||
# Freeline
|
|
||||||
freeline.py
|
|
||||||
freeline/
|
|
||||||
freeline_project_description.json
|
|
||||||
|
|
||||||
# fastlane
|
|
||||||
fastlane/report.xml
|
|
||||||
fastlane/Preview.html
|
|
||||||
fastlane/screenshots
|
|
||||||
fastlane/test_output
|
|
||||||
fastlane/readme.md
|
|
||||||
|
|
||||||
# Version control
|
|
||||||
vcs.xml
|
|
||||||
|
|
||||||
# lint
|
|
||||||
lint/intermediates/
|
|
||||||
lint/generated/
|
|
||||||
lint/outputs/
|
|
||||||
lint/tmp/
|
|
||||||
# lint/reports/
|
|
||||||
|
|
||||||
# Android Profiling
|
|
||||||
*.hprof
|
|
||||||
|
|
||||||
# Cordova plugins for Capacitor
|
|
||||||
capacitor-cordova-android-plugins
|
|
||||||
|
|
||||||
# Copied web assets
|
|
||||||
app/src/main/assets/public
|
|
||||||
|
|
||||||
# Generated Config files
|
|
||||||
app/src/main/assets/capacitor.config.json
|
|
||||||
app/src/main/assets/capacitor.plugins.json
|
|
||||||
app/src/main/res/xml/config.xml
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/build/*
|
|
||||||
!/build/.npmkeep
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
apply plugin: 'com.android.application'
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "ru.eruditgame.app"
|
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
|
||||||
|
|
||||||
// Release signing is supplied by the android-build CI workflow via env: it decodes the keystore
|
|
||||||
// secret to a file and points ANDROID_KEYSTORE_FILE at it. A local build or a keyless CI run leaves
|
|
||||||
// it unset, so the release APK is produced UNSIGNED rather than failing — assembleDebug and
|
|
||||||
// assembleRelease both build without the keystore. The keystore is never committed (see .gitignore).
|
|
||||||
def keystoreFile = System.getenv('ANDROID_KEYSTORE_FILE')
|
|
||||||
def hasKeystore = keystoreFile != null && !keystoreFile.isEmpty() && file(keystoreFile).exists()
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "ru.eruditgame.app"
|
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
|
||||||
// Deterministic from the release tag by the android-build workflow (vMA.MI.PA ->
|
|
||||||
// MA*1_000_000 + MI*1_000 + PA); the defaults keep a local assembleDebug building.
|
|
||||||
versionCode = (project.findProperty('versionCode') ?: '1').toInteger()
|
|
||||||
versionName = (project.findProperty('versionName') ?: '0.0.0').toString()
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
aaptOptions {
|
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
|
||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
|
||||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
signingConfigs {
|
|
||||||
release {
|
|
||||||
// Populated only when the workflow provided a keystore (see hasKeystore above); left empty
|
|
||||||
// otherwise so it is never referenced with a null storeFile.
|
|
||||||
if (hasKeystore) {
|
|
||||||
storeFile file(keystoreFile)
|
|
||||||
storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
|
|
||||||
keyAlias System.getenv('ANDROID_KEY_ALIAS')
|
|
||||||
keyPassword System.getenv('ANDROID_KEY_PASSWORD')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
minifyEnabled false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
||||||
// Sign only when a keystore was supplied; a keyless release build stays unsigned instead
|
|
||||||
// of failing.
|
|
||||||
if (hasKeystore) {
|
|
||||||
signingConfig signingConfigs.release
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
flatDir{
|
|
||||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
|
||||||
implementation project(':capacitor-android')
|
|
||||||
testImplementation "junit:junit:$junitVersion"
|
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
|
||||||
implementation project(':capacitor-cordova-android-plugins')
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: 'capacitor.build.gradle'
|
|
||||||
|
|
||||||
try {
|
|
||||||
def servicesJSON = file('google-services.json')
|
|
||||||
if (servicesJSON.text) {
|
|
||||||
apply plugin: 'com.google.gms.google-services'
|
|
||||||
}
|
|
||||||
} catch(Exception e) {
|
|
||||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_21
|
|
||||||
targetCompatibility JavaVersion.VERSION_21
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
|
||||||
dependencies {
|
|
||||||
implementation project(':capacitor-app')
|
|
||||||
implementation project(':capacitor-network')
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (hasProperty('postBuildExtras')) {
|
|
||||||
postBuildExtras()
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Add project specific ProGuard rules here.
|
|
||||||
# You can control the set of applied configuration files using the
|
|
||||||
# proguardFiles setting in build.gradle.
|
|
||||||
#
|
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
|
||||||
# class:
|
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
|
||||||
# public *;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
|
||||||
# debugging stack traces.
|
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
|
||||||
# hide the original source file name.
|
|
||||||
#-renamesourcefileattribute SourceFile
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package com.getcapacitor.myapp;
|
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry;
|
|
||||||
import org.junit.Test;
|
|
||||||
import org.junit.runner.RunWith;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented test, which will execute on an Android device.
|
|
||||||
*
|
|
||||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4.class)
|
|
||||||
public class ExampleInstrumentedTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void useAppContext() throws Exception {
|
|
||||||
// Context of the app under test.
|
|
||||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
|
||||||
|
|
||||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/AppTheme">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:label="@string/title_activity_main"
|
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:exported="true">
|
|
||||||
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths"></meta-data>
|
|
||||||
</provider>
|
|
||||||
</application>
|
|
||||||
|
|
||||||
<!-- Permissions -->
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
</manifest>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package ru.eruditgame.app;
|
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {}
|
|
||||||
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,34 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportHeight="108"
|
|
||||||
android:viewportWidth="108">
|
|
||||||
<path
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="78.5885"
|
|
||||||
android:endY="90.9159"
|
|
||||||
android:startX="48.7653"
|
|
||||||
android:startY="61.0927"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportHeight="108"
|
|
||||||
android:viewportWidth="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#26A69A"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M9,0L9,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,0L19,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,0L29,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
tools:context=".MainActivity">
|
|
||||||
|
|
||||||
<WebView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent" />
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background>
|
|
||||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
|
||||||
</background>
|
|
||||||
<foreground>
|
|
||||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
|
||||||
</foreground>
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background>
|
|
||||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
|
||||||
</background>
|
|
||||||
<foreground>
|
|
||||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
|
||||||
</foreground>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 913 B |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 754 B |
|
Before Width: | Height: | Size: 333 B |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 564 B |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#FFFFFF</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">Эрудит</string>
|
|
||||||
<string name="title_activity_main">Эрудит</string>
|
|
||||||
<string name="package_name">ru.eruditgame.app</string>
|
|
||||||
<string name="custom_url_scheme">ru.eruditgame.app</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
|
|
||||||
<!-- Base application theme. -->
|
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
|
||||||
<item name="windowActionBar">false</item>
|
|
||||||
<item name="windowNoTitle">true</item>
|
|
||||||
<item name="android:background">@null</item>
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
|
||||||
<item name="android:background">@drawable/splash</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<external-path name="my_images" path="." />
|
|
||||||
<cache-path name="my_cache_images" path="." />
|
|
||||||
</paths>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.getcapacitor.myapp;
|
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
|
||||||
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
|
||||||
*
|
|
||||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
|
||||||
*/
|
|
||||||
public class ExampleUnitTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void addition_isCorrect() throws Exception {
|
|
||||||
assertEquals(4, 2 + 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
|
||||||
|
|
||||||
buildscript {
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
dependencies {
|
|
||||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
|
||||||
classpath 'com.google.gms:google-services:4.4.4'
|
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
|
||||||
// in the individual module build.gradle files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: "variables.gradle"
|
|
||||||
|
|
||||||
allprojects {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task clean(type: Delete) {
|
|
||||||
delete rootProject.buildDir
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
|
||||||
include ':capacitor-android'
|
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.4.1_@capacitor+core@8.4.1/node_modules/@capacitor/android/capacitor')
|
|
||||||
|
|
||||||
include ':capacitor-app'
|
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.4.1/node_modules/@capacitor/app/android')
|
|
||||||
|
|
||||||
include ':capacitor-network'
|
|
||||||
project(':capacitor-network').projectDir = new File('../node_modules/.pnpm/@capacitor+network@8.0.1_@capacitor+core@8.4.1/node_modules/@capacitor/network/android')
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Project-wide Gradle settings.
|
|
||||||
|
|
||||||
# IDE (e.g. Android Studio) users:
|
|
||||||
# Gradle settings configured through the IDE *will override*
|
|
||||||
# any settings specified in this file.
|
|
||||||
|
|
||||||
# For more details on how to configure your build environment visit
|
|
||||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
|
||||||
|
|
||||||
# Specifies the JVM arguments used for the daemon process.
|
|
||||||
# The setting is particularly useful for tweaking memory settings.
|
|
||||||
org.gradle.jvmargs=-Xmx1536m
|
|
||||||
|
|
||||||
# When configured, Gradle will run in incubating parallel mode.
|
|
||||||
# This option should only be used with decoupled projects. More details, visit
|
|
||||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
|
||||||
# org.gradle.parallel=true
|
|
||||||
|
|
||||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
|
||||||
# Android operating system, and which are packaged with your app's APK
|
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
|
||||||
android.useAndroidX=true
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright © 2015-2021 the original authors.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
|
||||||
#
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
#
|
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
|
||||||
#
|
|
||||||
# Important for running:
|
|
||||||
#
|
|
||||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
|
||||||
# noncompliant, but you have some other compliant shell such as ksh or
|
|
||||||
# bash, then to run this script, type that shell name before the whole
|
|
||||||
# command line, like:
|
|
||||||
#
|
|
||||||
# ksh Gradle
|
|
||||||
#
|
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
|
||||||
# requires all of these POSIX shell features:
|
|
||||||
# * functions;
|
|
||||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
|
||||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
|
||||||
# * compound commands having a testable exit status, especially «case»;
|
|
||||||
# * various built-in commands including «command», «set», and «ulimit».
|
|
||||||
#
|
|
||||||
# Important for patching:
|
|
||||||
#
|
|
||||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
|
||||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
|
||||||
#
|
|
||||||
# The "traditional" practice of packing multiple parameters into a
|
|
||||||
# space-separated string is a well documented source of bugs and security
|
|
||||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
|
||||||
# options in "$@", and eventually passing that to Java.
|
|
||||||
#
|
|
||||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
|
||||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
|
||||||
# see the in-line comments for details.
|
|
||||||
#
|
|
||||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
|
||||||
# Darwin, MinGW, and NonStop.
|
|
||||||
#
|
|
||||||
# (3) This script is generated from the Groovy template
|
|
||||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
|
||||||
# within the Gradle project.
|
|
||||||
#
|
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
|
||||||
#
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
|
||||||
|
|
||||||
# Resolve links: $0 may be a link
|
|
||||||
app_path=$0
|
|
||||||
|
|
||||||
# Need this for daisy-chained symlinks.
|
|
||||||
while
|
|
||||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
|
||||||
[ -h "$app_path" ]
|
|
||||||
do
|
|
||||||
ls=$( ls -ld "$app_path" )
|
|
||||||
link=${ls#*' -> '}
|
|
||||||
case $link in #(
|
|
||||||
/*) app_path=$link ;; #(
|
|
||||||
*) app_path=$APP_HOME$link ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# This is normally unused
|
|
||||||
# shellcheck disable=SC2034
|
|
||||||
APP_BASE_NAME=${0##*/}
|
|
||||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
|
||||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
|
||||||
MAX_FD=maximum
|
|
||||||
|
|
||||||
warn () {
|
|
||||||
echo "$*"
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
die () {
|
|
||||||
echo
|
|
||||||
echo "$*"
|
|
||||||
echo
|
|
||||||
exit 1
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
|
||||||
cygwin=false
|
|
||||||
msys=false
|
|
||||||
darwin=false
|
|
||||||
nonstop=false
|
|
||||||
case "$( uname )" in #(
|
|
||||||
CYGWIN* ) cygwin=true ;; #(
|
|
||||||
Darwin* ) darwin=true ;; #(
|
|
||||||
MSYS* | MINGW* ) msys=true ;; #(
|
|
||||||
NONSTOP* ) nonstop=true ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
CLASSPATH="\\\"\\\""
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
|
||||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
|
||||||
else
|
|
||||||
JAVACMD=$JAVA_HOME/bin/java
|
|
||||||
fi
|
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
JAVACMD=java
|
|
||||||
if ! command -v java >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
|
||||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|
||||||
case $MAX_FD in #(
|
|
||||||
max*)
|
|
||||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
|
||||||
# shellcheck disable=SC2039,SC3045
|
|
||||||
MAX_FD=$( ulimit -H -n ) ||
|
|
||||||
warn "Could not query maximum file descriptor limit"
|
|
||||||
esac
|
|
||||||
case $MAX_FD in #(
|
|
||||||
'' | soft) :;; #(
|
|
||||||
*)
|
|
||||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
|
||||||
# shellcheck disable=SC2039,SC3045
|
|
||||||
ulimit -n "$MAX_FD" ||
|
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Collect all arguments for the java command, stacking in reverse order:
|
|
||||||
# * args from the command line
|
|
||||||
# * the main class name
|
|
||||||
# * -classpath
|
|
||||||
# * -D...appname settings
|
|
||||||
# * --module-path (only if needed)
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
|
||||||
if "$cygwin" || "$msys" ; then
|
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
|
||||||
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
for arg do
|
|
||||||
if
|
|
||||||
case $arg in #(
|
|
||||||
-*) false ;; # don't mess with options #(
|
|
||||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
|
||||||
[ -e "$t" ] ;; #(
|
|
||||||
*) false ;;
|
|
||||||
esac
|
|
||||||
then
|
|
||||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
|
||||||
fi
|
|
||||||
# Roll the args list around exactly as many times as the number of
|
|
||||||
# args, so each arg winds up back in the position where it started, but
|
|
||||||
# possibly modified.
|
|
||||||
#
|
|
||||||
# NB: a `for` loop captures its iteration list before it begins, so
|
|
||||||
# changing the positional parameters here affects neither the number of
|
|
||||||
# iterations, nor the values presented in `arg`.
|
|
||||||
shift # remove old arg
|
|
||||||
set -- "$@" "$arg" # push replacement arg
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|
||||||
|
|
||||||
# Collect all arguments for the java command:
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
|
||||||
# and any embedded shellness will be escaped.
|
|
||||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
|
||||||
# treated as '${Hostname}' itself on the command line.
|
|
||||||
|
|
||||||
set -- \
|
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
|
||||||
-classpath "$CLASSPATH" \
|
|
||||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
|
||||||
"$@"
|
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
|
||||||
if ! command -v xargs >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
die "xargs is not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Use "xargs" to parse quoted args.
|
|
||||||
#
|
|
||||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
|
||||||
#
|
|
||||||
# In Bash we could simply go:
|
|
||||||
#
|
|
||||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
|
||||||
# set -- "${ARGS[@]}" "$@"
|
|
||||||
#
|
|
||||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
|
||||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
|
||||||
# character that might be a shell metacharacter, then use eval to reverse
|
|
||||||
# that process (while maintaining the separation between arguments), and wrap
|
|
||||||
# the whole thing up as a single "set" statement.
|
|
||||||
#
|
|
||||||
# This will of course break if any of these variables contains a newline or
|
|
||||||
# an unmatched quote.
|
|
||||||
#
|
|
||||||
|
|
||||||
eval "set -- $(
|
|
||||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
|
||||||
xargs -n1 |
|
|
||||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
|
||||||
tr '\n' ' '
|
|
||||||
)" '"$@"'
|
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
@rem
|
|
||||||
@rem Copyright 2015 the original author or authors.
|
|
||||||
@rem
|
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
@rem you may not use this file except in compliance with the License.
|
|
||||||
@rem You may obtain a copy of the License at
|
|
||||||
@rem
|
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
@rem
|
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
@rem See the License for the specific language governing permissions and
|
|
||||||
@rem limitations under the License.
|
|
||||||
@rem
|
|
||||||
@rem SPDX-License-Identifier: Apache-2.0
|
|
||||||
@rem
|
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
|
||||||
@rem ##########################################################################
|
|
||||||
@rem
|
|
||||||
@rem Gradle startup script for Windows
|
|
||||||
@rem
|
|
||||||
@rem ##########################################################################
|
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
|
||||||
@rem This is normally unused
|
|
||||||
set APP_BASE_NAME=%~n0
|
|
||||||
set APP_HOME=%DIRNAME%
|
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
|
||||||
|
|
||||||
@rem Find java.exe
|
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:execute
|
|
||||||
@rem Setup the command line
|
|
||||||
|
|
||||||
set CLASSPATH=
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
|
||||||
|
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
include ':app'
|
|
||||||
include ':capacitor-cordova-android-plugins'
|
|
||||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
|
||||||
|
|
||||||
apply from: 'capacitor.settings.gradle'
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
ext {
|
|
||||||
minSdkVersion = 24
|
|
||||||
compileSdkVersion = 36
|
|
||||||
targetSdkVersion = 36
|
|
||||||
androidxActivityVersion = '1.11.0'
|
|
||||||
androidxAppCompatVersion = '1.7.1'
|
|
||||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
|
||||||
androidxCoreVersion = '1.17.0'
|
|
||||||
androidxFragmentVersion = '1.8.9'
|
|
||||||
coreSplashScreenVersion = '1.2.0'
|
|
||||||
androidxWebkitVersion = '1.14.0'
|
|
||||||
junitVersion = '4.13.2'
|
|
||||||
androidxJunitVersion = '1.3.0'
|
|
||||||
androidxEspressoCoreVersion = '3.7.0'
|
|
||||||
cordovaAndroidVersion = '14.0.1'
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 46 KiB |
@@ -1,12 +0,0 @@
|
|||||||
import type { CapacitorConfig } from '@capacitor/cli';
|
|
||||||
|
|
||||||
const config: CapacitorConfig = {
|
|
||||||
appId: 'ru.eruditgame.app',
|
|
||||||
appName: 'Эрудит',
|
|
||||||
webDir: 'dist',
|
|
||||||
// Bundle model: no server.url — the WebView loads the packaged dist/ from app assets. Updates
|
|
||||||
// ship through the store; the client-version gate turns away a build too old to speak the
|
|
||||||
// current wire contract (docs/ARCHITECTURE.md §2).
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -38,20 +38,7 @@ 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();
|
||||||
@@ -62,7 +49,6 @@ test('the chooser downloads the PNG through the signed URL on a plain browser',
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the chooser downloads the GCG through the same signed-URL route', async ({ page }) => {
|
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();
|
||||||
@@ -212,7 +198,6 @@ test('Telegram on iOS keeps the app chooser and opens the OS share sheet', async
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => {
|
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.
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { test, expect, type Page } from './fixtures';
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
// Offline pass-and-play (hotseat) is offered only in offline mode, which is now implicit (the net-state
|
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
|
||||||
// machine detects it — no toggle). The e2e drives the machine through the mock's __net hook. Standalone
|
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
|
||||||
// display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an
|
// Settings offline toggle shows.
|
||||||
// installed PWA.
|
|
||||||
async function forceStandalone(page: Page): Promise<void> {
|
async function forceStandalone(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const orig = window.matchMedia.bind(window);
|
const orig = window.matchMedia.bind(window);
|
||||||
@@ -20,9 +19,11 @@ async function enterLobby(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function goOffline(page: Page): Promise<void> {
|
async function goOffline(page: Page): Promise<void> {
|
||||||
// The machine auto-detects offline (no toggle, no dialog); the lobby stays mounted and turns blue.
|
await page.locator('button.tab').nth(2).click(); // Settings
|
||||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||||
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||||
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||||
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
||||||
@@ -45,12 +46,9 @@ test.describe('offline hotseat (pass-and-play)', () => {
|
|||||||
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
||||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
||||||
|
|
||||||
// New game -> "with friends" offers an online (invite) / offline (hotseat) segment. Offline, the
|
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||||
// online segment is disabled and the offline (pass-and-play) one is forced, so the hotseat form shows.
|
|
||||||
await page.locator('button.tab').nth(0).click();
|
await page.locator('button.tab').nth(0).click();
|
||||||
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||||
await expect(page.getByRole('button', { name: /Invite a friend|Пригласить друга/i })).toBeDisabled();
|
|
||||||
await expect(page.locator('.hostpin')).toBeVisible();
|
|
||||||
|
|
||||||
// The player rows are disabled until the mandatory host (master) PIN is set.
|
// The player rows are disabled until the mandatory host (master) PIN is set.
|
||||||
await expect(page.locator('.pname').first()).toBeDisabled();
|
await expect(page.locator('.pname').first()).toBeDisabled();
|
||||||
@@ -117,12 +115,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
|
|||||||
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
|
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
|
||||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||||
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(1);
|
await expect(page.locator('.rowwrap')).toHaveCount(1);
|
||||||
await page.locator('.kebab').first().click();
|
await page.locator('.kebab').first().click();
|
||||||
const del = page.locator('.rowwrap.revealed .del');
|
const del = page.locator('.rowwrap.revealed .del');
|
||||||
await expect(del).toBeVisible();
|
await expect(del).toBeVisible();
|
||||||
await del.click();
|
await del.click();
|
||||||
await typePin(page, '9999');
|
await typePin(page, '9999');
|
||||||
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(0);
|
await expect(page.locator('.rowwrap')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { test, expect, type Page } from './fixtures';
|
|
||||||
|
|
||||||
// Simulate the native (Capacitor) channel. @capacitor/core — loaded during boot by initNativeShell —
|
|
||||||
// derives the platform from window.androidBridge / window.webkit and REPLACES any pre-set
|
|
||||||
// window.Capacitor with its own shim (whose getPlatform() would otherwise report 'web'), so the
|
|
||||||
// reliable native signal is the bridge marker, injected before any app script runs. clientChannel()
|
|
||||||
// then resolves to 'android', which flips the offline-first cold boot (app.svelte.ts), the loader's
|
|
||||||
// bundled-dict tier (lib/dict/loader.ts) and every native gate. The bridge is a stub with no real
|
|
||||||
// native runtime behind it; initNativeShell tolerates that (its @capacitor/app addListener is wrapped).
|
|
||||||
async function simulateNative(page: Page): Promise<void> {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
(window as unknown as { androidBridge: { postMessage: () => void } }).androidBridge = { postMessage: () => {} };
|
|
||||||
(window as unknown as { Capacitor: { getPlatform: () => string } }).Capacitor = { getPlatform: () => 'android' };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// The native cold boot skips the blocking login and lands straight in the lobby as a device-local
|
|
||||||
// guest in (auto) offline mode: the header carries the offline tint and the tab bar is present with
|
|
||||||
// no guest sign-in click. Offline the seeded online game (vs Ann) is hidden and Stats is disabled.
|
|
||||||
async function expectOfflineGuestLobby(page: Page): Promise<void> {
|
|
||||||
await expect(page.locator('header.nav.offline')).toBeVisible();
|
|
||||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
|
||||||
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
|
|
||||||
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pick a rack tile by its glyph and drop it on a board square (the pinned-seed rack is all-distinct).
|
|
||||||
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
|
|
||||||
await page.locator('.rack .tile', { hasText: glyph }).first().click();
|
|
||||||
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// typePin clicks the on-screen keypad digits (mirrors hotseat.spec — the pad has no OK button, so the
|
|
||||||
// 4th digit is the action; wait for the dots to clear so a call right after a previous entry keeps them).
|
|
||||||
async function typePin(page: Page, digits: string): Promise<void> {
|
|
||||||
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
|
|
||||||
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('native offline-first', () => {
|
|
||||||
test('cold-boots to the offline guest lobby, plays local vs_ai, reconciles a server guest online', async ({ page }) => {
|
|
||||||
await simulateNative(page);
|
|
||||||
await page.goto('/');
|
|
||||||
await expectOfflineGuestLobby(page);
|
|
||||||
|
|
||||||
// Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary
|
|
||||||
// comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no
|
|
||||||
// network is needed — this is the device-local guest playing with zero connectivity.
|
|
||||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
|
||||||
await page.locator('button.tab').nth(0).click();
|
|
||||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
|
||||||
await page.locator('button.invite').click();
|
|
||||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
|
||||||
|
|
||||||
// The human plays WAY horizontally across the centre (7,5)-(7,7); the local robot replies with a
|
|
||||||
// real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three.
|
|
||||||
await placeTile(page, 'W', 7, 5);
|
|
||||||
await placeTile(page, 'A', 7, 6);
|
|
||||||
await placeTile(page, 'Y', 7, 7);
|
|
||||||
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
|
||||||
await page.locator('.make').click();
|
|
||||||
await expect(async () => {
|
|
||||||
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
|
|
||||||
}).toPass({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Back to the lobby (still offline): the device-local vs_ai game (🤖) is listed. Then the network
|
|
||||||
// returns: reconciliation silently mints + adopts a server guest and clears the auto-offline, so
|
|
||||||
// online lights up — the offline tint drops, the seeded online game (vs Ann) appears and Stats
|
|
||||||
// re-enables. The local game STAYS visible in the unified lobby alongside the server games — a
|
|
||||||
// reconciled guest keeps its device-local games (this closes G-step-0).
|
|
||||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
|
||||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
|
||||||
await expect(page.locator('.rowwrap').filter({ hasText: '🤖' })).toBeVisible(); // the local vs_ai game, listed while offline
|
|
||||||
await page.evaluate(() => (window as unknown as { __native: { reconcile(): void } }).__native.reconcile());
|
|
||||||
await expect(page.locator('header.nav.offline')).toHaveCount(0);
|
|
||||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible({ timeout: 15000 });
|
|
||||||
await expect(page.locator('.rowwrap').filter({ hasText: '🤖' })).toBeVisible(); // still listed online — the reconciled guest keeps it (G-step-0)
|
|
||||||
await expect(page.locator('button.tab').nth(1)).toBeEnabled();
|
|
||||||
|
|
||||||
// The native sign-in surface is guest + email only: on Profile the Telegram + VK LINK buttons are
|
|
||||||
// hidden (their login widget / full-page redirect cannot return into the WebView), while email account
|
|
||||||
// management stays. loginWidgetAvailable/vkWebLinkAvailable are true under the mock, so on web these
|
|
||||||
// buttons DO show (social.spec links Telegram there) — a count of 0 here proves the native gate, not a
|
|
||||||
// merely-absent button.
|
|
||||||
await page.getByRole('button', { name: /Settings/ }).click();
|
|
||||||
await page.getByRole('button', { name: 'Profile', exact: true }).click();
|
|
||||||
await expect(page.getByRole('button', { name: /Link Telegram/i })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole('button', { name: /Link VK/i })).toHaveCount(0);
|
|
||||||
await expect(page.getByText('you@example.com')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('starts a 2-player hotseat game as an offline guest', async ({ page }) => {
|
|
||||||
await simulateNative(page);
|
|
||||||
await page.goto('/');
|
|
||||||
await expectOfflineGuestLobby(page);
|
|
||||||
|
|
||||||
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest.
|
|
||||||
// A minimal create: set the mandatory host (master) PIN, decline a seat, English, two open seats.
|
|
||||||
await page.locator('button.tab').nth(0).click();
|
|
||||||
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
|
||||||
await page.locator('.hostpin .plink').click();
|
|
||||||
await typePin(page, '9999');
|
|
||||||
await typePin(page, '9999');
|
|
||||||
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
|
||||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
|
||||||
await page.locator('.pname').nth(0).fill('Ann');
|
|
||||||
await page.locator('.pname').nth(1).fill('Bob');
|
|
||||||
await page.locator('button.invite').click();
|
|
||||||
|
|
||||||
// The hotseat game starts: the board renders — a 2-player local pass-and-play game running with no
|
|
||||||
// network, reached straight from the native offline-first boot as the device-local guest.
|
|
||||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||