Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e836dfefba | |||
| b5fe61279b | |||
| e07886ecb1 | |||
| 4a3e12c85a | |||
| 54bc31c619 | |||
| d89438040b | |||
| f0b7ad47d4 | |||
| 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 |
|
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 FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.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)]
|
||||
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_foreground.png'), await shot(im(FG, 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>`;
|
||||
writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
|
||||
// Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
|
||||
// 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>`;
|
||||
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
|
||||
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):
|
||||
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
|
||||
// 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] };
|
||||
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻).
|
||||
// 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 = {
|
||||
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**
|
||||
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_monochrome.png` — themed layer
|
||||
- `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
|
||||
layer, so the sacrificial outer ring is just more wood.
|
||||
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
|
||||
the mask:
|
||||
- «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**.
|
||||
- ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**,
|
||||
the mask. The whole mark is the master's optical placement (nudged right 8 % / down 3 %)
|
||||
scaled **0.75 about the icon centre** — **25 % smaller**, its «Э» ↔ ✻ arrangement
|
||||
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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
// body on the public listener is then capped (the admin proxy POSTs included);
|
||||
// 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,
|
||||
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 |
|
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 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.
|
||||
// Play a local Erudit vs_ai game with a pinned bag seed (deals the rack ОЖЬЯНАО). Erudit is the
|
||||
// only variant the profileless offline guest is offered (the new-account default), so the single
|
||||
// `.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.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 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);
|
||||
// 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 НОЖ's three.
|
||||
await placeTile(page, 'Н', 7, 5);
|
||||
await placeTile(page, 'О', 7, 6);
|
||||
await placeTile(page, 'Ж', 7, 7);
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
||||
await page.locator('.make').click();
|
||||
await expect(async () => {
|
||||
@@ -95,14 +97,15 @@ test.describe('native offline-first', () => {
|
||||
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.
|
||||
// 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.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('.variant').click();
|
||||
await page.locator('.pname').nth(0).fill('Ann');
|
||||
await page.locator('.pname').nth(1).fill('Bob');
|
||||
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 {
|
||||
ALL_VARIANTS,
|
||||
DEFAULT_VARIANTS,
|
||||
availableVariants,
|
||||
supportsMultipleWordsToggle,
|
||||
multipleWordsForRequest,
|
||||
@@ -16,9 +17,11 @@ describe('ALL_VARIANTS', () => {
|
||||
});
|
||||
|
||||
describe('availableVariants', () => {
|
||||
it('is ungated (all variants) for an empty or absent preference set', () => {
|
||||
expect(availableVariants([])).toEqual(ALL_VARIANTS);
|
||||
expect(availableVariants(undefined)).toEqual(ALL_VARIANTS);
|
||||
it('falls back to the Erudit-only default for an empty or absent preference set', () => {
|
||||
const fallback = ALL_VARIANTS.filter((v) => DEFAULT_VARIANTS.includes(v.id));
|
||||
expect(fallback.map((v) => v.id)).toEqual(['erudit_ru']);
|
||||
expect(availableVariants([])).toEqual(fallback);
|
||||
expect(availableVariants(undefined)).toEqual(fallback);
|
||||
});
|
||||
|
||||
it('offers only the preferred variants', () => {
|
||||
|
||||
@@ -62,13 +62,23 @@ export function usesStarBlank(v: Variant): boolean {
|
||||
return v === 'erudit_ru';
|
||||
}
|
||||
|
||||
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
|
||||
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
|
||||
// — a safety fallback; a real profile always carries at least one preference.
|
||||
// DEFAULT_VARIANTS is the variant set a player sees before any preference is stored — a fresh
|
||||
// or offline native client whose profile has not been synced yet (the device-local guest boots
|
||||
// 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[] {
|
||||
const prefs = preferences ?? [];
|
||||
if (prefs.length === 0) return ALL_VARIANTS;
|
||||
return ALL_VARIANTS.filter((v) => prefs.includes(v.id));
|
||||
const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
|
||||
return ALL_VARIANTS.filter((v) => effective.includes(v.id));
|
||||
}
|
||||
|
||||
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle
|
||||
|
||||