Compare commits

..

40 Commits

Author SHA1 Message Date
developer e836dfefba Merge pull request 'release: promote development → master (v1.21.0)' (#272) from development into master 2026-07-14 20:29:04 +00:00
developer b5fe61279b Merge pull request 'android: shrink adaptive icon mark 25% + full-bleed legacy square + Erudit-only default for the profileless client' (#271) from feature/native-icons-variant-defaults into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 20:23:04 +00:00
Ilia Denisov e07886ecb1 test(ui): update the native offline-first e2e for the Erudit-only guest default
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
The offline native guest is now offered only Erudit (the profileless default
from the variants fix), so the specs that picked the English "Scrabble"
variant timed out. Play Erudit instead: the single `.variant` click both
selects it and asserts nothing else is offered; the vs_ai test plays НОЖ from
the pinned Erudit rack (ОЖЬЯНАО), and the bundled ru_erudit dawg drives the
robot reply.
2026-07-14 22:13:42 +02:00
Ilia Denisov 4a3e12c85a feat(icons): shrink the Android adaptive mark 25%, give old square launchers the full-bleed master
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 2m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The adaptive foreground's asterisk grazed the round mask's safe zone. Scale the
foreground mark 0.75 about the icon centre (25% smaller, the letter-to-star
arrangement unchanged) so it sits well inside the 61% safe zone. Independently,
the legacy square ic_launcher.png now renders the full-bleed master instead of
the safe-zone composite, so old Android (< API 26) shows a large mark rather
than a shrunk one; the legacy round icon keeps the safe-zone composite (a round
mask would clip the master's corner star). Regenerate the layer set, Android
res and the brandbook foreground previews; update ICON_BRANDBOOK.md / ICONS.md.
2026-07-14 21:57:32 +02:00
Ilia Denisov 54bc31c619 fix(ui): default the profileless client to Erudit only, not every variant
availableVariants fell back to all three variants when the player had no
stored preferences. A fresh offline native launch boots with no profile, so
New Game exposed the English game before the player opted in — contrary to the
backend's Erudit-only new-account default (docs/FUNCTIONAL.md). Fall back to
DEFAULT_VARIANTS (Erudit only) instead; all dictionaries stay bundled.
2026-07-14 21:57:21 +02:00
developer d89438040b Merge pull request 'fix(gateway): allow the native WebView origin via CORS (native stuck-offline)' (#270) from feature/native-cors into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-14 19:38:27 +00:00
developer 363f1632b1 Merge pull request 'android: ANDROID_PLAN refresh + pin build-tools 36 (unblocks the release build)' (#269) from feature/android-plan-refresh into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
2026-07-14 19:38:13 +00:00
Ilia Denisov f0b7ad47d4 fix(gateway): allow the native WebView origin via CORS
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The packaged native app (Capacitor) serves the bundled SPA from a localhost scheme, so its Connect calls to erudit-game.ru are cross-origin. The gateway had no CORS handling, so the preflight OPTIONS returned 405 with no Access-Control-Allow-Origin and the WebView blocked every RPC — a native build could never reach the gateway and stayed stuck offline (the native online path was never exercised on-device; on-device D was airplane-mode only). Add a CORS middleware that answers the preflight and sets the response headers for the native localhost origins (https/http/capacitor://localhost); web is same-origin and untouched. Fixes the native emulator 'starts offline, can't go online' report.
2026-07-14 21:21:53 +02:00
Ilia Denisov 0e11817654 fix(android): pin build-tools to 36.0.0 for all modules
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m51s
AGP 8.13 defaults to build-tools 35.0.0, but the provisioned / read-only CI SDK (/opt/android-sdk) has only 36.0.0 installed, so a gradle build (the app + the cap-sync-generated Capacitor modules) fails trying to auto-install 35 into a read-only dir. Pin buildToolsVersion 36.0.0 for every Android sub-project (compileSdk-matching) so the CI android-build signed release and local builds work. Verified: a local assembleDebug now builds app-debug.apk. ci.yaml does not build Android (only the manual android-build workflow does), so this is verified locally.
2026-07-14 20:43:58 +02:00
Ilia Denisov d0e473920c docs(android): refresh the ANDROID_PLAN RESUME block
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
Keystore + its Gitea secrets are set (owner) under the RuStore-specific ANDROID_RUSTORE_* names; the payments E10/E11 (#266/#267) + the signing-secret rename (#268) landed on development; the release gate is now the RuStore account + the promote/tag chain. Adds a native-notifications (push) track and the test-APK note for the next session.
2026-07-14 20:24:26 +02:00
developer 39e03d22e4 Merge pull request 'release: v1.20.1 — real client IP to backend (last-login fix) + host UTC' (#265) from development into master 2026-07-14 09:44:43 +00:00
developer aaf162de40 Merge pull request 'release: v1.20.0 — offline-UX, catalog order + admin toggles, reward-config admin' (#263) from development into master 2026-07-14 09:02:54 +00:00
developer 5ce9f7e9b4 Merge pull request 'chore(release): promote development to master' (#258) from development into master 2026-07-13 22:21:59 +00:00
developer 3456a0b3ff Merge pull request 'release: v1.18.0 — /support command in the Telegram bot' (#252) from development into master 2026-07-13 07:39:06 +00:00
developer a41281c495 Merge pull request 'release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby' (#250) from development into master 2026-07-13 01:06:35 +00:00
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
42 changed files with 200 additions and 47 deletions
+28 -17
View File
@@ -62,19 +62,20 @@ client-only (no server change).
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code. Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
> ### ▶ RESUME HERE — 2026-07-13 (documents track + release-prep) > ### ▶ RESUME HERE — 2026-07-14 (keystore secrets set; payments landed; gate = RuStore account + merge/tag)
> >
> **Where we are.** The legal-documents track, the release-prep hygiene, **and the icon rebrand** are > **Where we are.** The legal-documents track, the release-prep hygiene, the icon rebrand **and the
> DONE. The RuStore release is now gated on **owner-side** items only (keystore, RuStore account) plus > release keystore** are DONE (the keystore + its Gitea secrets set by the owner — see below). The
> the merge/tag chain. Do not re-do or re-litigate the finished work; do not re-ask the locked decisions. > RuStore MVP is now gated on **one owner-side item** — the **RuStore developer account** (owner going
> ИП; RuStore updated its requirements) — plus the agent's **promote/tag → signed-build** chain. Also
> landed on `development` since: the payments **E10 multi-shop split** (#266) + **E11 kill switch**
> (#267), both dormant / fail-open (no bearing on the Android build), and the signing-secret rename
> `ANDROID_*` → **`ANDROID_RUSTORE_*`** (#268). Do not re-do the finished work or re-ask the locked
> decisions.
> >
> **Branches / PRs (current working branch: `feature/android-release-prep`).** > **Branches / PRs.** All merged to `development` (contour-green): legal documents (#253); release-prep
> - `development` — has the **legal-documents track**, merged via **PR #253** (green, deployed to the > hygiene + the re-enabled `android-build` workflow (#254); the payments E10/E11 (#266/#267); the
> test contour). `/privacy/`, `/eula/`, `/offer/` are live there, bilingual RU/EN. > signing-secret rename (#268). No open Android branch.
> - `feature/android-release-prep` — the **release-prep hygiene**, **PR #254 OPEN, green, NOT merged**
> (the owner chose to hold the merge). It is branched off `development`, so it also contains the
> legal-documents track. Merge #254 into `development` when ready — needs **owner approval** (the
> agent cannot self-approve).
> >
> **Done this session.** > **Done this session.**
> - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored > - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored
@@ -113,12 +114,12 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
> 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and > 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and
> **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the > **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the
> `X-Client-Version` header). > `X-Client-Version` header).
> 3. **Release keystore.** OWNER generates it (`keytool -genkeypair -v -keystore erudit-release.jks > 3. **Release keystore — DONE (owner, 2026-07-14).** `erudit-release.jks` (RSA 4096, alias `erudit`,
> -alias erudit -keyalg RSA -keysize 4096 -validity 10000`), base64-encodes it, sets the four Gitea > PKCS12) generated + backed up off-host; the four Gitea secrets are set under the RuStore-specific
> **secrets** `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / > names `ANDROID_RUSTORE_KEYSTORE_BASE64` / `ANDROID_RUSTORE_KEYSTORE_PASSWORD` /
> `ANDROID_KEY_PASSWORD`, and backs the `.jks` up **off-host** (loss ⇒ the app can never be updated). > `ANDROID_RUSTORE_KEY_ALIAS` / `ANDROID_RUSTORE_KEY_PASSWORD` (loss ⇒ the app can never be updated
> The AGENT provides the exact commands on request. Without the secrets the workflow builds an > the cert is pinned at the first RuStore upload). Google Play, later, would use its own
> UNSIGNED APK (a dry run still proves the pipeline). > `ANDROID_PLAY_*` upload key (Play App Signing) — a separate key.
> 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates > 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates
> publication, not the build. > publication, not the build.
> 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via > 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via
@@ -132,6 +133,16 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
> 8. **Upload to RuStore.** OWNER. After publication, set the `ANDROID_RUSTORE_URL` Gitea variable to > 8. **Upload to RuStore.** OWNER. After publication, set the `ANDROID_RUSTORE_URL` Gitea variable to
> the store deep-link so the update button works on future gated releases (empty until then — the > the store deep-link so the update button works on future gated releases (empty until then — the
> gate is dormant in the MVP). > gate is dormant in the MVP).
>
> **Next tracks (design in a future session — interview the owner first).**
> - **Native notifications (push).** Promote FCM/push from "Out of scope" to a real track: a Capacitor
> push plugin, the Android send path (FCM — but **check RuStore's own push service vs FCM on RuStore
> devices**, since Google Play Services may be absent), token registration, and reusing the existing
> server notification dispatcher. Owner will bring specifics.
> - **A test APK for the emulator** (owner asked): the signed APK comes from `android-build` (needs the
> promote/tag chain, step 2). A quick **debug** APK is buildable without the release chain if the host
> has the Android SDK + JDK 21 (`pnpm build` → `cap sync` → `assembleDebug`).
> - Possibly other native polish (owner to flag).
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated - **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+8 -2
View File
@@ -19,6 +19,11 @@ const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png')); const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png')); const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
const MONO = uri(join(DIR, 'android-monochrome.png')); const MONO = uri(join(DIR, 'android-monochrome.png'));
// The full-bleed master (square tile + master mark) — the legacy SQUARE launcher (API < 26)
// uses it so old Android shows a large mark rather than the safe-zone foreground shrunk into
// the middle. The round legacy icon keeps the safe-zone composite (a round mask would clip
// the master's corner ✻).
const MASTER = uri(join(ROOT, 'ui', 'assets', 'icon.png'));
// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)] // density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] }; const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
@@ -38,8 +43,9 @@ for (const [d, [fg, lg]] of Object.entries(D)) {
writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false)); writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false));
writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true)); writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true));
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true)); writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
const comp = `<div style="position:relative;width:${lg}px;height:${lg}px">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`; // Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false)); // composite, circle-clipped (the master's corner ✻ would be clipped by the round mask).
writeFileSync(join(dir, 'ic_launcher.png'), await shot(im(MASTER, lg), lg, false));
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`; const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true)); writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
console.log('mipmap-' + d, 'ok'); console.log('mipmap-' + d, 'ok');
+6 -2
View File
@@ -33,8 +33,12 @@ const SHADE = 0.15; // black, this alpha bottom-left -> transp
// The mark, placed for the standalone master (bottom-right biased, full-bleed): // The mark, placed for the standalone master (bottom-right biased, full-bleed):
const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] }; const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe // The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻): // zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻).
const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] }; // Every value is the earlier placement scaled 0.75 about the icon centre (0.5, 0.5): the
// whole mark is 25% smaller, its «Э»↔✻ arrangement unchanged, so the ✻ that used to graze
// the round mask now sits well inside the safe zone. Round-mask launchers (adaptive + the
// legacy round icon) get more breathing room; the full-bleed master is untouched.
const FOREGROUND = { lh: 0.4043, lc: [0.5176, 0.4963], sd: 0.0989, sc: [0.6970, 0.6700] };
const PALETTE = { const PALETTE = {
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' }, light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+4 -1
View File
@@ -54,7 +54,10 @@ already fill the 108 dp canvas (foreground inside the 61 % safe zone, background
full-bleed), so they must be placed with **no inset**, and we add the **monochrome** full-bleed), so they must be placed with **no inset**, and we add the **monochrome**
themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi): themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi):
- `mipmap-*/ic_launcher.png` / `ic_launcher_round.png` — legacy square / round (composite) - `mipmap-*/ic_launcher.png` — legacy square (< API 26): the **full-bleed master** (large
mark), so old square launchers do not shrink it into the safe zone
- `mipmap-*/ic_launcher_round.png` — legacy round (< API 26): the safe-zone **composite**,
circle-clipped (a round mask would clip the master's corner ✻)
- `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers - `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers
- `mipmap-*/ic_launcher_monochrome.png` — themed layer - `mipmap-*/ic_launcher_monochrome.png` — themed layer
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml`**no `<inset>`**, with - `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml`**no `<inset>`**, with
+6 -5
View File
@@ -123,12 +123,13 @@ So Android is **not** the full-bleed master — it is built as two layers:
and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp
layer, so the sacrificial outer ring is just more wood. layer, so the sacrificial outer ring is just more wood.
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside - **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
the mask: the mask. The whole mark is the master's optical placement (nudged right 8 % / down 3 %)
- «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**. scaled **0.75 about the icon centre****25 % smaller**, its «Э» ↔ ✻ arrangement
- ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**, unchanged — so the ✻ that used to graze the round mask now sits well inside the **61 %
safe zone** with margin to spare:
- «Э»: box height **40.4 %**, box center **51.8 % / 49.6 %**.
- ✻: outer diameter **9.9 %** (smaller than the master's), center **69.7 % / 67.0 %**,
tucked closer to the letter. tucked closer to the letter.
- The mark is centred, then nudged **right 8 % / down 3 %** so it reads optically
centred under the round mask, and the whole group fits within the **61 % safe zone**.
Everything else (palette, grain, gradients, star construction) is identical to the master. Everything else (palette, grain, gradients, star construction) is identical to the master.
+47
View File
@@ -0,0 +1,47 @@
package connectsrv
import "net/http"
// nativeWebViewOrigins are the browser Origins of the packaged native apps. Capacitor serves the
// bundled SPA from a localhost scheme, so its Connect calls to the gateway are cross-origin; the web
// app is same-origin and needs no entry. Requests carry Authorization, so the allowlist reflects the
// exact origin rather than "*".
var nativeWebViewOrigins = map[string]bool{
"https://localhost": true, // Capacitor Android (default https scheme)
"http://localhost": true, // Capacitor with the http scheme
"capacitor://localhost": true, // Capacitor iOS default scheme
}
// withNativeCORS answers the CORS preflight and adds the CORS response headers for the packaged
// native apps' cross-origin calls to the Connect edge. Without it the WebView blocks every RPC on the
// preflight (the gateway otherwise returns 405 with no Access-Control-Allow-Origin), so a native
// build can never reach the gateway and stays stuck offline. Same-origin (web) and any non-native
// origin are untouched.
func withNativeCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if nativeWebViewOrigins[origin] {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
h.Set("Access-Control-Allow-Credentials", "true")
// The client interceptor reads the soft-tier update signal off the response.
h.Set("Access-Control-Expose-Headers", "X-Update-Recommended")
if r.Method == http.MethodOptions {
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
// Reflect the requested headers (the origin is already allowlisted): the Connect client
// sends Connect-Protocol-Version / Connect-Timeout-Ms plus X-Client-Version and
// Authorization, and the exact set varies by call.
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
h.Set("Access-Control-Allow-Headers", reqHeaders)
} else {
h.Set("Access-Control-Allow-Headers", "Content-Type, Connect-Protocol-Version, X-Client-Version, Authorization")
}
h.Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return
}
}
next.ServeHTTP(w, r)
})
}
+54
View File
@@ -0,0 +1,54 @@
package connectsrv
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestWithNativeCORS(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
h := withNativeCORS(next)
// Native-origin preflight → 204 with the CORS headers, reflecting the requested headers, without
// reaching the inner handler.
req := httptest.NewRequest(http.MethodOptions, "/scrabble.edge.v1.Gateway/Execute", nil)
req.Header.Set("Origin", "https://localhost")
req.Header.Set("Access-Control-Request-Method", "POST")
req.Header.Set("Access-Control-Request-Headers", "content-type,connect-protocol-version,x-client-version")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://localhost" {
t.Errorf("Allow-Origin = %q, want https://localhost", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "content-type,connect-protocol-version,x-client-version" {
t.Errorf("Allow-Headers = %q, want the reflected set", got)
}
// Native-origin POST → the CORS headers are set and the inner handler runs.
req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil)
req.Header.Set("Origin", "capacitor://localhost")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("POST status = %d, want 200 (passed through)", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
t.Errorf("POST Allow-Origin = %q, want capacitor://localhost", got)
}
// A non-native origin gets no CORS headers and still passes through (web is same-origin anyway).
req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil)
req.Header.Set("Origin", "https://evil.example")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("non-native POST status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("non-native Allow-Origin = %q, want empty", got)
}
}
+1 -1
View File
@@ -302,7 +302,7 @@ func (s *Server) HTTPHandler() http.Handler {
// honeypot hit is turned away before the body cap and the mux. Every request // honeypot hit is turned away before the body cap and the mux. Every request
// body on the public listener is then capped (the admin proxy POSTs included); // body on the public listener is then capped (the admin proxy POSTs included);
// the h2c server carries explicit stream/idle sizing. // the h2c server carries explicit stream/idle sizing.
return h2c.NewHandler(s.abuseGuard(maxBodyHandler(s.maxBodyBytes, mux)), &http2.Server{ return h2c.NewHandler(s.abuseGuard(withNativeCORS(maxBodyHandler(s.maxBodyBytes, mux))), &http2.Server{
MaxConcurrentStreams: h2cMaxConcurrentStreams, MaxConcurrentStreams: h2cMaxConcurrentStreams,
IdleTimeout: h2cIdleTimeout, IdleTimeout: h2cIdleTimeout,
}) })
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+11
View File
@@ -24,6 +24,17 @@ allprojects {
} }
} }
// Pin build-tools to the compileSdk-matching version for every Android module (the app + the
// Capacitor modules regenerated by `cap sync`), so AGP does not fall back to its default (35.0.0),
// which the provisioned / read-only CI SDK does not have installed.
subprojects {
afterEvaluate {
if (project.hasProperty('android')) {
android.buildToolsVersion = "36.0.0"
}
}
}
task clean(type: Delete) { task clean(type: Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+14 -11
View File
@@ -43,20 +43,22 @@ test.describe('native offline-first', () => {
await page.goto('/'); await page.goto('/');
await expectOfflineGuestLobby(page); await expectOfflineGuestLobby(page);
// Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary // Play a local Erudit vs_ai game with a pinned bag seed (deals the rack ОЖЬЯНАО). Erudit is the
// comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no // only variant the profileless offline guest is offered (the new-account default), so the single
// network is needed — this is the device-local guest playing with zero connectivity. // `.variant` click both selects it and asserts nothing else is on offer. The dictionary comes from
// the APK's bundled tier (./dict/ru_erudit@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.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
await page.locator('button.tab').nth(0).click(); await page.locator('button.tab').nth(0).click();
await page.locator('.variant', { hasText: 'Scrabble' }).click(); await page.locator('.variant').click();
await page.locator('button.invite').click(); await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible(); 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 // The human plays НОЖ 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. // real move (which needs the bundled dictionary), so the board gains tiles beyond НОЖ's three.
await placeTile(page, 'W', 7, 5); await placeTile(page, 'Н', 7, 5);
await placeTile(page, 'A', 7, 6); await placeTile(page, 'О', 7, 6);
await placeTile(page, 'Y', 7, 7); await placeTile(page, 'Ж', 7, 7);
await expect(page.locator('[data-cell].pending')).toHaveCount(3); await expect(page.locator('[data-cell].pending')).toHaveCount(3);
await page.locator('.make').click(); await page.locator('.make').click();
await expect(async () => { await expect(async () => {
@@ -95,14 +97,15 @@ test.describe('native offline-first', () => {
await expectOfflineGuestLobby(page); await expectOfflineGuestLobby(page);
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest. // 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. // A minimal create: set the mandatory host (master) PIN, decline a seat, the sole offered variant
// (Erudit — the profileless guest's default), two open seats.
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 page.locator('.hostpin .plink').click(); await page.locator('.hostpin .plink').click();
await typePin(page, '9999'); await typePin(page, '9999');
await typePin(page, '9999'); await typePin(page, '9999');
await page.getByRole('button', { name: /^(No|Нет)$/ }).click(); await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
await page.locator('.variant', { hasText: 'Scrabble' }).click(); await page.locator('.variant').click();
await page.locator('.pname').nth(0).fill('Ann'); await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob'); await page.locator('.pname').nth(1).fill('Bob');
await page.locator('button.invite').click(); await page.locator('button.invite').click();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

+6 -3
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { import {
ALL_VARIANTS, ALL_VARIANTS,
DEFAULT_VARIANTS,
availableVariants, availableVariants,
supportsMultipleWordsToggle, supportsMultipleWordsToggle,
multipleWordsForRequest, multipleWordsForRequest,
@@ -16,9 +17,11 @@ describe('ALL_VARIANTS', () => {
}); });
describe('availableVariants', () => { describe('availableVariants', () => {
it('is ungated (all variants) for an empty or absent preference set', () => { it('falls back to the Erudit-only default for an empty or absent preference set', () => {
expect(availableVariants([])).toEqual(ALL_VARIANTS); const fallback = ALL_VARIANTS.filter((v) => DEFAULT_VARIANTS.includes(v.id));
expect(availableVariants(undefined)).toEqual(ALL_VARIANTS); expect(fallback.map((v) => v.id)).toEqual(['erudit_ru']);
expect(availableVariants([])).toEqual(fallback);
expect(availableVariants(undefined)).toEqual(fallback);
}); });
it('offers only the preferred variants', () => { it('offers only the preferred variants', () => {
+15 -5
View File
@@ -62,13 +62,23 @@ export function usesStarBlank(v: Variant): boolean {
return v === 'erudit_ru'; return v === 'erudit_ru';
} }
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set // DEFAULT_VARIANTS is the variant set a player sees before any preference is stored — a fresh
// they enabled in Settings). An empty or absent set is ungated (returns every variant) // or offline native client whose profile has not been synced yet (the device-local guest boots
// — a safety fallback; a real profile always carries at least one preference. // with no profile at all). It mirrors the backend's new-account default (the account service
// seeds Erudit only), so the local guest and a later synced account agree on what is enabled.
// Every dictionary is still bundled; the other variants are simply off until the player turns
// them on in Settings.
export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru'];
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set they
// enabled in Settings). An empty or absent set falls back to DEFAULT_VARIANTS rather than every
// variant: a real profile always carries at least one preference, and a profileless client (a
// fresh offline native launch) must match the server's Erudit-only default instead of exposing
// the English game before the player opts in.
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] { export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
const prefs = preferences ?? []; const prefs = preferences ?? [];
if (prefs.length === 0) return ALL_VARIANTS; const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
return ALL_VARIANTS.filter((v) => prefs.includes(v.id)); return ALL_VARIANTS.filter((v) => effective.includes(v.id));
} }
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle // supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle