Compare commits
40 Commits
0e56e4de9a
...
v1.21.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e836dfefba | |||
| b5fe61279b | |||
| e07886ecb1 | |||
| 4a3e12c85a | |||
| 54bc31c619 | |||
| d89438040b | |||
| 363f1632b1 | |||
| f0b7ad47d4 | |||
| 0e11817654 | |||
| d0e473920c | |||
| 39e03d22e4 | |||
| aaf162de40 | |||
| 5ce9f7e9b4 | |||
| 3456a0b3ff | |||
| a41281c495 | |||
| 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 |
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 19 KiB |
@@ -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');
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
@@ -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();
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||