From e0772585678277430513a29069ad017efb22c084 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 12 Jul 2026 18:03:52 +0200 Subject: [PATCH] feat(ui): offline-first native boot + lazy server-guest reconciliation Native (Capacitor) cold boot with no cached session now enters as a device-local guest in auto-offline mode and lands straight in the lobby (never /login), so the app opens and plays local vs_ai / hotseat with the APK's bundled dictionaries and zero network. When the gateway becomes reachable, reconcileServerGuest silently mints + adopts a server guest and clears the auto-offline, lighting up online features. Web / PWA / Telegram / VK are byte-for-byte unchanged. - transport: exec gains { silent, allowOffline } so background reconciliation bypasses the offline kill switch (like the reachability probe) and never raises the terminal update overlay (a too-old client stays a local guest); new authGuestSilent on the client interface, the real transport and the mock. - app: native no-session boot branch; reconcileServerGuest fired at boot, by the recovery poll and the online event; the poll routes the session-less guest through reconciliation, since checkReachable needs a token it lacks. - native: initNativeShell tolerates a missing Capacitor bridge. - e2e: new native.spec.ts (inject window.androidBridge; boot -> offline lobby, local vs_ai move, reconcile -> online, hotseat start) + playwright.config bundles the dawgs into dist-e2e/dict for the loader's bundled tier. --- .claude/CLAUDE.md | 14 +++- ANDROID_PLAN.md | 148 ++++++++++++++++++++++---------------- ui/e2e/native.spec.ts | 100 ++++++++++++++++++++++++++ ui/playwright.config.ts | 8 ++- ui/src/lib/app.svelte.ts | 68 ++++++++++++++++-- ui/src/lib/client.ts | 4 ++ ui/src/lib/mock/client.ts | 6 ++ ui/src/lib/native.ts | 17 +++-- ui/src/lib/transport.ts | 32 +++++++-- 9 files changed, 318 insertions(+), 79 deletions(-) create mode 100644 ui/e2e/native.spec.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index db427f8..4f87c1a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -114,10 +114,20 @@ The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` eyeball per-variant tiles. - **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a plugin-config gotcha in wiring them). -- **The `playwright test` runner can't fetch browsers in this sandbox** — `playwright install` dies +- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in **CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP** - browser against a local `vite --mode mock` server for visual verification. + browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are + already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN + fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit), + its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback. +- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.** + `@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import) + **replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from + `window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected + `Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject + `window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`); + `initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener). - **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env). Use **testcontainers** for container-backed tests. - **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600) diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index d77436d..a0bacd1 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -91,16 +91,32 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif `clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl. `update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app store-update SDK noted Out of scope. -- **D. Offline-first — 🚧 IN PROGRESS (foundations done & committed `bcd5a1d`, 2026-07-12).** The - additive, web-inert groundwork landed; the boot rewrite + reconciliation + Profile soft-sign-in + - tests remain (see §D for the detailed remaining path and the session decisions). Done: - `ui/scripts/bundle-dicts.mjs` (release DAWGs → `dist/dict/@.dawg`, keyed on - `VITE_DICT_VERSION`, `OUT_DIR` override for the e2e); the `dict/loader.ts` **bundled tier** (between - IndexedDB and network, **native-gated** — see the §D.1 correction); `__DICT_VERSION__` vite define + - declaration; `lib/localguest.ts` (persisted device-local guest id, no DB row) + `common.guest` i18n; - `NewGame.svelte` offline vs_ai/hotseat creates fall back to `__DICT_VERSION__` and - `localGuestId()`/`t('common.guest')` when there is no session (inert until the boot lands). - `svelte-check` 0, `vitest` 591, native + web builds clean. +- **D. Offline-first — 🚧 IN PROGRESS (D.1–D.4 done; D.6 + the D-close verification remain).** + - **D.1 + D.2 (foundations) — ✅ DONE & committed `bcd5a1d` (2026-07-12).** `ui/scripts/bundle-dicts.mjs` + (release DAWGs → `dist/dict/@.dawg`, keyed on `VITE_DICT_VERSION`, `OUT_DIR` override + for the e2e); the `dict/loader.ts` **bundled tier** (between IndexedDB and network, **native-gated** — + see the §D.1 correction); `__DICT_VERSION__` vite define + declaration; `lib/localguest.ts` (persisted + device-local guest id, no DB row) + `common.guest` i18n; `NewGame.svelte` offline vs_ai/hotseat creates + fall back to `__DICT_VERSION__` and `localGuestId()`/`t('common.guest')` when there is no session. + - **D.3 (cold boot) + D.4 (reconciliation + silent seam) — ✅ DONE & verified (2026-07-12).** The native + no-session cold boot lands in the offline lobby as a device-local guest (`app.svelte.ts` bootstrap + `else if (native)` branch — `localGuestId()` + `setOfflineMode(true,false)` + `scheduleRecovery(0)`); + lazy `reconcileServerGuest()` mints + adopts a server guest and clears the auto-offline when the gateway + is reachable (kicked at boot + by the recovery poll + the online event). Silent seam: + `transport.ts` `exec` gained `{ silent, allowOffline }` (suppress the update overlay **and** bypass the + kill switch), plus `authGuestSilent(locale)` on the `GatewayClient` interface, the real transport and the + mock. `native.ts` `initNativeShell` made bridge-tolerant. New `e2e/native.spec.ts` (Capacitor injected; + boot→offline-lobby, local vs_ai move, hotseat start, reconcile→online) + `playwright.config.ts` bundles + the dawgs into `dist-e2e/dict/`. `svelte-check` 0, `vitest` 591, web + native builds clean; e2e runs in CI. + See §D.3/§D.4 for the as-built corrections (shouldBootOffline **not** changed; `allowOffline` + + checkReachable-token findings; the `__native.reconcile` e2e hook). + - **D.6 (Profile tg/vk hide on native) — ▢ TODO** (owner deferred to a follow-up this session). + - **DEFERRED DECISION (owner-agreed 2026-07-12) — native local-game visibility.** The online lobby lists + only server games (`Lobby.svelte:42/54`), so a native guest's **device-local vs_ai/hotseat games hide from + the lobby once reconciled online** (the pre-existing offline↔online split). For native this IS the primary + flow, so the behaviour **must change** — a native guest should still see / resume their local games when + online. Owner agreed it needs changing but wants to design it **at the very end of the Android work (before + release — see §G)**; out of D.3/D.4 scope, tracked here so it is not lost. - **E–G — pending.** Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate @@ -456,38 +472,44 @@ the tests remain. **Verify every line ref below against current code** (they are (`~line 99`) now uses `accountId: app.session?.userId ?? localGuestId()` and `name: app.profile?.displayName ?? t('common.guest')`. Hotseat seats stay independent local identities (`buildSeats` — unchanged). -3. **Cold offline-first boot — ▢ TODO** (`ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`). **High - blast-radius: this is app startup for every platform — gate every change on the native channel and - leave the web / PWA / TG / VK paths byte-for-byte.** - - The blocking Login is **`bootstrap()` app.svelte.ts ~line 989-991**: - `} else if (router.route.name !== 'login' && router.route.name !== 'confirm') { navigate('/login'); }` - — the `else` of `if (saved)` (no cached session). **On native, replace this branch** with the - offline-first entry: establish the local guest (`localGuestId()`), `setOfflineMode(true, false)` - (**non-sticky** auto-offline, so reconciliation may clear it — cf. the sticky arg at ~line 968), and - land in the **lobby** (leave the route / `navigate('/')`), `app.ready = true`. Never navigate to - `/login` on native. - - The native no-session boot **always lands in the lobby**, online or offline. If reachable at boot, - also kick reconciliation (D.4); if not, stay a local guest until the network returns. The lobby - renders without a session — offline mode shows local games and `NewGame` gates its offline flows on - `guest || offlineMode.active`. - - `shouldBootOffline` (offline.ts:85, currently `offlineActive && hasSession && hasProfile`): add a - **native-no-session** path so a native cold start can boot offline as the local guest with no cached - session/profile. Keep the web rule intact. - - `offlinePreloadEligible` (offline.ts:69): **bypass the server preload on native** — the dicts are - bundled, so there is nothing to preload; keep the web `standalone && hasEmail` gate. -4. **Lazy server-guest reconciliation + the silent seam — ▢ TODO** (the seam was deferred from C). - - **Silent seam:** the C transport calls `reportUpdateRequired()` in three places — `exec` result-code - branch (`transport.ts ~line 86`), `exec` catch (`~line 73`), `subscribe` catch (`~line 366`). Add a - **silent path** that does NOT raise the overlay: e.g. an `exec(msgType, payload, signal, opts?: { - silent?: boolean })` flag, plus an `authGuestSilent(locale)` on the `GatewayClient` interface - (`lib/client.ts`), the real transport, **and the mock** (`lib/mock/client.ts` — the e2e reconciliation - leg goes through the mock). Foreground `auth.*` keep the overlay. - - **Reconciliation:** when native + online + **no** server session → background `authGuestSilent`, - `saveSession` + adopt (`setToken` + `app.session`/`app.profile`, or reuse `adoptSession`), then - **clear the auto-offline** (`setOfflineMode(false)`) so online features + Profile light up. Guard - duplicates (only when no cached session — never mint a second server guest). On `update_required` it - stays offline silently (no overlay). Local games stay device-only. Fire it on boot (if reachable) and - on network-recovery (a connection-online handler). +3. **Cold offline-first boot — ✅ DONE & verified (2026-07-12)** (`ui/src/lib/app.svelte.ts`). **High + blast-radius: app startup for every platform — every change is native-gated; web / PWA / TG / VK stay + byte-for-byte.** As built: + - The blocking Login was **`bootstrap()`'s `else` of `if (saved)`** (no cached session, formerly + `navigate('/login')`). On native it is now the offline-first entry: `localGuestId()` + + `setOfflineMode(true, false)` (**non-sticky** auto-offline, so reconciliation may clear it) + land in + the lobby (`navigate('/')` only if on login/confirm) + `scheduleRecovery(0)` to kick reconciliation. + Web keeps the `navigate('/login')` else. `app.ready = true`. + - **CORRECTION — `shouldBootOffline` was NOT changed.** The native-no-session case is fully caught in + that `else` branch (there `hasSession` is provably false and `saved`'s `if`-block — the web + canOffline logic — is skipped), so a `native` param would be unused in the load-bearing path. The + plan's "add a native-no-session path to `shouldBootOffline`" is dropped, and the matching unit test + with it (the boot decision is exercised by `e2e/native.spec.ts`). + - **`offlinePreloadEligible` was NOT changed either** — it already returns false off a standalone web + PWA (native is not `isStandalone()`), so the server preload is already skipped on native; no edit needed. + - The lobby renders without a session/profile: `Lobby.svelte`/`NewGame.svelte` use optional chaining + and the offline branch loads only device-local games (no session-gated `gamesList`). `NewGame` gates + its offline flows on `guest || offlineMode.active`, both true for the local guest. +4. **Lazy server-guest reconciliation + the silent seam — ✅ DONE & verified (2026-07-12).** As built: + - **Silent seam:** `exec` gained `opts?: { silent?: boolean; allowOffline?: boolean }`. + **CORRECTION — `silent` alone was not enough:** reconciliation runs while the app is still in + auto-offline, so `exec`'s `assertOnline()` kill switch would refuse it — hence **`allowOffline`** + (bypass the kill switch, like the reachability probe). `silent` gates the two `exec` + `reportUpdateRequired()` sites (result-code + catch); the `subscribe` catch keeps the overlay + (reconciliation never subscribes). New `authGuestSilent(locale)` on the `GatewayClient` interface + (`lib/client.ts`), the real transport (`{ silent: true, allowOffline: true }`) and the mock. + - **Reconciliation — `reconcileServerGuest()` (`app.svelte.ts`):** native + no session → `authGuestSilent`, + `setOfflineMode(false)` **before** `adoptSession` (so adopt's `profileGet`/stream ride the online + transport, not the kill switch), then adopt. Guarded by `reconcileInFlight` + `app.session` (never a + second guest). `update_required`/unreachable are swallowed (stay a local guest). Local games stay + device-only. Fired at boot and by the recovery poll + the online event. + - **CORRECTION — `checkReachable` cannot gate a session-less guest:** its authenticated probe rejects + with no token, so it never reports "reachable" for a local guest. The recovery poll (`scheduleRecovery`) + therefore routes the **no-session** case straight through `reconcileServerGuest` (the mint attempt IS + the reachability check); the session case keeps `checkReachable`. + - **`scheduleRecovery` is now mock-guarded** (like `initNetworkReactivity`) so the e2e's boot does not + race a mock reconcile to online before the spec observes the offline lobby; the e2e drives + reconciliation via a new **`window.__native.reconcile()`** hook (mirrors `__conn`/`__maint`/`__update`). 5. **Both offline modes render for the local guest — ✅ mostly (verify at D close).** `NewGame` gates the offline flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both "quick" (vs_ai) and "with friends" (hotseat) show. The vs_ai human seat already uses the local-guest id @@ -504,24 +526,27 @@ the tests remain. **Verify every line ref below against current code** (they are `clientChannel()` android/ios check to hide both on the native build; keep email + account management. (See the decision block above for why.) -**Tests — ▢ TODO** (docs/TESTING.md layers; the mock e2e bypasses the codec, so the gate's server path -stays in the Go tests): -- **Unit (vitest, node):** the bundled-dict tier — a `fetch` mock returning a dawg blob **with the native - channel forced** (`vi.stubGlobal('window', { Capacitor: { getPlatform: () => 'android' } })`, since the - tier is native-gated; `idbPutDawg`/`requestPersist` are best-effort `void`); `shouldBootOffline` - native-no-session; `localguest` (`localGuestId` persistence + `isLocalGuestId`). (`update.svelte.ts`-style - reminder: a `$state` rune module can't be imported under this project's plugin-less vitest.) -- **Playwright offline-first spec:** **simulate native** by injecting `window.Capacitor = { getPlatform: - () => 'android' }` via `addInitScript` (makes `clientChannel()` return `android`, activating the bundled - tier + the native boot). Place bundled dicts at **`dist-e2e/dict/@dev.dawg`** — extend the - `playwright.config.ts` webServer command to also run `DICT_DIR=$E2E_DICT_DIR OUT_DIR=dist-e2e node - scripts/bundle-dicts.mjs` (`VITE_DICT_VERSION` unset → `dev`, matching `__DICT_VERSION__`). Boot with the - network hook off (`window.__conn.offline()` / the offline pref) → assert the **lobby** (not login), play a - local vs_ai move, start a hotseat game; then network on → reconciliation lights up. - **GOTCHA:** with `window.Capacitor` injected, `initNativeShell` (`App.svelte:40`) will `await - import('@capacitor/app')` and call `App.addListener('backButton', …)` — there is no real Capacitor - bridge in the browser, so guard/stub it (inject a fake `Capacitor.Plugins.App`, or make `initNativeShell` - tolerate a missing bridge) or the boot may throw. +**Tests — ✅ DONE for D.3/D.4 (2026-07-12):** +- **Unit (vitest, node):** no NEW unit test was added for D.3/D.4 — the boot decision, reconciliation and + silent seam live in `$state`/transport modules the plugin-less vitest cannot import (cf. `update.svelte.ts`), + and the `shouldBootOffline` native path was dropped (§D.3). `localguest` + the bundled-dict tier are D.1/D.2 + coverage (a bundled-tier `fetch`-mock unit test remains a D-close nicety if wanted). `vitest` stays 591. +- **Playwright — new `e2e/native.spec.ts`** (NOT an extension of `offline.spec`): **GOTCHA — inject + `window.androidBridge`, NOT `window.Capacitor.getPlatform`.** `@capacitor/core` (loaded during boot by + `initNativeShell`) derives the platform from `window.androidBridge` / `window.webkit` and **replaces** any + pre-set `window.Capacitor` with its own shim whose `getPlatform()` is `web` — so a bare injected + `window.Capacitor = { getPlatform: () => 'android' }` is clobbered and the boot falls to `/login`. + `simulateNative` injects `window.androidBridge = { postMessage(){} }` (+ a Capacitor stub for the pre-core + window), which makes `clientChannel()` resolve to `android`. Two tests: (1) boot → **assert the offline guest + lobby (not login)**, play a local vs_ai move (bundled dawg), Back, `window.__native.reconcile()` → assert + **online lights up** (offline tint gone, the seeded online game appears, Stats re-enabled); (2) boot → start a + 2-player hotseat game. The `playwright.config.ts` webServer now also runs `DICT_DIR="${E2E_DICT_DIR:-…}" + OUT_DIR=dist-e2e node scripts/bundle-dicts.mjs` → `dist-e2e/dict/@.dawg` (version flows from + `VITE_DICT_VERSION`, default `dev`, matching `__DICT_VERSION__`). **Ran locally green (chromium + webkit, 4/4) + against the installed browsers + the sibling `scrabble-solver/dawg`; also runs in CI.** +- **GOTCHA resolved:** `initNativeShell` was made **bridge-tolerant** (`try/catch` around the `@capacitor/app` + import + `addListener`) rather than stubbing a fake Capacitor bridge — cleaner and defensive for a real WebView + that lacks the App plugin. **Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on @@ -586,6 +611,9 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned). ### G. Release + owner handoff +0. **Resolve the deferred native local-game-visibility decision (Progress §D):** a native guest must still + see / resume their device-local vs_ai / hotseat games once reconciled online (today the online lobby hides + them — `Lobby.svelte:42/54`). Design + implement, or explicitly accept the split for the MVP, before release. 1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant). 2. Promote `development` → `master`; tag `vX.Y.0`. 3. Dispatch `android-build`; watch green; retrieve the signed APK. diff --git a/ui/e2e/native.spec.ts b/ui/e2e/native.spec.ts new file mode 100644 index 0000000..c532c9e --- /dev/null +++ b/ui/e2e/native.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, type Page } from './fixtures'; + +// Simulate the native (Capacitor) channel. @capacitor/core — loaded during boot by initNativeShell — +// derives the platform from window.androidBridge / window.webkit and REPLACES any pre-set +// window.Capacitor with its own shim (whose getPlatform() would otherwise report 'web'), so the +// reliable native signal is the bridge marker, injected before any app script runs. clientChannel() +// then resolves to 'android', which flips the offline-first cold boot (app.svelte.ts), the loader's +// bundled-dict tier (lib/dict/loader.ts) and every native gate. The bridge is a stub with no real +// native runtime behind it; initNativeShell tolerates that (its @capacitor/app addListener is wrapped). +async function simulateNative(page: Page): Promise { + await page.addInitScript(() => { + (window as unknown as { androidBridge: { postMessage: () => void } }).androidBridge = { postMessage: () => {} }; + (window as unknown as { Capacitor: { getPlatform: () => string } }).Capacitor = { getPlatform: () => 'android' }; + }); +} + +// The native cold boot skips the blocking login and lands straight in the lobby as a device-local +// guest in (auto) offline mode: the header carries the offline tint and the tab bar is present with +// no guest sign-in click. Offline the seeded online game (vs Ann) is hidden and Stats is disabled. +async function expectOfflineGuestLobby(page: Page): Promise { + await expect(page.locator('header.nav.offline')).toBeVisible(); + await expect(page.locator('button.tab').nth(2)).toBeVisible(); + await expect(page.getByText('Ann', { exact: false })).toHaveCount(0); + await expect(page.locator('button.tab').nth(1)).toBeDisabled(); +} + +// Pick a rack tile by its glyph and drop it on a board square (the pinned-seed rack is all-distinct). +async function placeTile(page: Page, glyph: string, row: number, col: number): Promise { + await page.locator('.rack .tile', { hasText: glyph }).first().click(); + await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click(); +} + +// typePin clicks the on-screen keypad digits (mirrors hotseat.spec — the pad has no OK button, so the +// 4th digit is the action; wait for the dots to clear so a call right after a previous entry keeps them). +async function typePin(page: Page, digits: string): Promise { + await expect(page.locator('.pad .dot.on')).toHaveCount(0); + for (const d of digits) await page.locator('.pad .key', { hasText: d }).click(); +} + +test.describe('native offline-first', () => { + test('cold-boots to the offline guest lobby, plays local vs_ai, reconciles a server guest online', async ({ page }) => { + await simulateNative(page); + await page.goto('/'); + await expectOfflineGuestLobby(page); + + // Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary + // comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no + // network is needed — this is the device-local guest playing with zero connectivity. + await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1')); + await page.locator('button.tab').nth(0).click(); + await page.locator('.variant', { hasText: 'Scrabble' }).click(); + await page.locator('button.invite').click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + + // The human plays WAY horizontally across the centre (7,5)-(7,7); the local robot replies with a + // real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three. + await placeTile(page, 'W', 7, 5); + await placeTile(page, 'A', 7, 6); + await placeTile(page, 'Y', 7, 7); + await expect(page.locator('[data-cell].pending')).toHaveCount(3); + await page.locator('.make').click(); + await expect(async () => { + expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3); + }).toPass({ timeout: 15000 }); + + // Back to the lobby, then the network returns: reconciliation silently mints + adopts a server + // guest and clears the auto-offline, so online lights up — the offline tint drops, the seeded + // online game (vs Ann) appears and the Stats tab re-enables. (The local vs_ai game stays + // device-only, so the online lobby list no longer shows it — that is the intended split.) + await page.getByRole('button', { name: /Back|Назад/i }).click(); + await expect(page.locator('button.tab').nth(2)).toBeVisible(); + await page.evaluate(() => (window as unknown as { __native: { reconcile(): void } }).__native.reconcile()); + await expect(page.locator('header.nav.offline')).toHaveCount(0); + await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible({ timeout: 15000 }); + await expect(page.locator('button.tab').nth(1)).toBeEnabled(); + }); + + test('starts a 2-player hotseat game as an offline guest', async ({ page }) => { + await simulateNative(page); + await page.goto('/'); + await expectOfflineGuestLobby(page); + + // New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest. + // A minimal create: set the mandatory host (master) PIN, decline a seat, English, two open seats. + await page.locator('button.tab').nth(0).click(); + await page.getByRole('button', { name: /With friends|друзьями/i }).click(); + await page.locator('.hostpin .plink').click(); + await typePin(page, '9999'); + await typePin(page, '9999'); + await page.getByRole('button', { name: /^(No|Нет)$/ }).click(); + await page.locator('.variant', { hasText: 'Scrabble' }).click(); + await page.locator('.pname').nth(0).fill('Ann'); + await page.locator('.pname').nth(1).fill('Bob'); + await page.locator('button.invite').click(); + + // The hotseat game starts: the board renders — a 2-player local pass-and-play game running with no + // network, reached straight from the native offline-first boot as the device-local guest. + await expect(page.locator('[data-cell]').first()).toBeVisible(); + }); +}); diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index e993a88..ae3afe3 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -22,10 +22,12 @@ export default defineConfig({ // /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset // URLs, so the minified JS under test is identical to the contour's. // After the mock build, copy the real per-variant dawgs into the preview output (dist-e2e) so the - // offline spec can play a real local vs_ai game; the files are e2e-only (never committed, never in - // the production build). Source: E2E_DICT_DIR (CI: the fetched release; local: the sibling). + // offline specs can play a real local vs_ai game; the files are e2e-only (never committed, never in + // the production build). Source: E2E_DICT_DIR (CI: the fetched release; local: the sibling). Two + // copies for the two loader tiers: e2edict/.dawg for the mock's session-gated fetchDict + // (network tier) and dict/@.dawg for the native offline-first spec's bundled tier. command: - 'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && node scripts/e2e-dict.mjs && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort', + 'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && node scripts/e2e-dict.mjs && DICT_DIR="${E2E_DICT_DIR:-../../scrabble-solver/dawg}" OUT_DIR=dist-e2e node scripts/bundle-dicts.mjs && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort', url: 'http://localhost:4173', reuseExistingServer: !process.env.CI, timeout: 120_000, diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 837621d..02e89b9 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -58,6 +58,8 @@ import type { CoachSeries } from './coachmark'; import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte'; import { offlineMode, setOfflineMode } from './offline.svelte'; import { shouldBootOffline } from './offline'; +import { clientChannel } from './channel'; +import { localGuestId } from './localguest'; import { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; import { advanceCached } from './gamedelta'; @@ -612,14 +614,24 @@ export function scheduleRecovery(delayMs: number): void { clearTimeout(recoveryTimer); recoveryTimer = null; } - if (typeof window === 'undefined' || !offlineMode.active || !offlineMode.auto) return; + // Web / native only; under the mock the e2e drives connectivity + reconciliation directly (cf. + // initNetworkReactivity), so the poll must not race a mock reconcile to online before the spec can + // observe the offline lobby. + if (typeof window === 'undefined' || import.meta.env.MODE === 'mock' || !offlineMode.active || !offlineMode.auto) return; recoveryTimer = setTimeout(async () => { recoveryTimer = null; if (!offlineMode.active || !offlineMode.auto) return; const interfaceUp = typeof navigator === 'undefined' || navigator.onLine; - if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) { - if (offlineMode.active && offlineMode.auto) setOfflineMode(false); - return; + if (interfaceUp) { + if (!app.session) { + // A native local guest has no session token for checkReachable's authenticated probe, so the + // reconciliation attempt itself is the reachability check: it mints + adopts a server guest and + // clears the auto-offline on success (a no-op off the native channel or once a session exists). + await reconcileServerGuest(); + } else if (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS)) { + if (offlineMode.active && offlineMode.auto) setOfflineMode(false); + } + if (!offlineMode.active) return; // came back online } scheduleRecovery(4000); }, delayMs); @@ -640,6 +652,38 @@ export function initNetworkReactivity(): void { }); } +// A concurrency guard: the boot kick, the recovery poll and the online event can all try to reconcile +// at once — only the first proceeds; the rest see the in-flight flag (or the adopted session) and return. +let reconcileInFlight = false; + +/** + * reconcileServerGuest lazily mints a server guest for a native local-guest launch once the gateway is + * reachable: with no cached session it silently calls auth.guest, adopts the returned session and clears + * the auto-offline so online features (matchmaking, friends, the Profile sign-in surface) light up. It is + * idempotent and best-effort — it never mints a second server guest (guarded on the absence of a session + * and the in-flight flag), and a too-old client (a swallowed update_required — no overlay) or an + * unreachable gateway just leaves the device a local guest until the next attempt. Local (device-only) + * games are never migrated. Native channel only; a no-op elsewhere. + */ +async function reconcileServerGuest(): Promise { + if (reconcileInFlight || app.session) return; + const channel = clientChannel(); + if (channel !== 'android' && channel !== 'ios') return; + reconcileInFlight = true; + try { + const s = await gateway.authGuestSilent(app.locale); + // The gateway answered, so leave auto-offline BEFORE adopting: adoptSession's profile fetch and live + // stream ride the normal (online) transport, which the offline kill switch would otherwise refuse. + if (offlineMode.active && offlineMode.auto) setOfflineMode(false); + await adoptSession(s); + } catch { + // A too-old client (update_required, swallowed — stay a local guest, no overlay) or an unreachable + // gateway: remain offline; the recovery poll / online event retries when the network returns. + } finally { + reconcileInFlight = false; + } +} + /** * applyLinkResult applies a completed account link or merge: it adopts a * switched session (a guest initiator whose durable counterpart won, so the active @@ -986,6 +1030,16 @@ export async function bootstrap(): Promise { } else if (router.route.name === 'login') { navigate('/'); } + } else if (clientChannel() === 'android' || clientChannel() === 'ios') { + // Native offline-first cold boot: no cached server session, so enter as a device-local guest in a + // non-sticky (auto) offline mode and land straight in the lobby — never /login. reconcileServerGuest + // (kicked here and by the recovery poll) mints a server guest and clears the auto-offline once the + // gateway is reachable; until then the guest plays local vs_ai / hotseat with the APK's bundled + // dictionaries. Web / PWA / Telegram / VK keep the online-session rule below. + localGuestId(); // establish + persist the device-local identity from the very first launch + setOfflineMode(true, false); + if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/'); + scheduleRecovery(0); // kick reconciliation now, then poll until the network returns } else if (router.route.name !== 'login' && router.route.name !== 'confirm') { navigate('/login'); } @@ -1370,4 +1424,10 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') { navigate, route: () => router.route.name, }; + // Drive the native offline-first reconciliation from the e2e: the recovery poll that fires it in a + // real build is skipped under the mock (scheduleRecovery), so the spec calls this to simulate "the + // network returned" and prove a server guest is minted + adopted and the auto-offline clears. + (window as unknown as { __native?: { reconcile(): void } }).__native = { + reconcile: () => void reconcileServerGuest(), + }; } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index a79ba4c..6cc7815 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -68,6 +68,10 @@ export interface GatewayClient { * cosmetic seed for a brand-new account). */ authVK(params: string, displayName: string): Promise; authGuest(locale?: string): Promise; + /** Mint a server guest for a native local guest gaining the network (background reconciliation). + * Unlike foreground auth it bypasses the offline kill switch and never raises the terminal update + * overlay — a too-old client stays a local guest instead (the gate×offline rule, docs/ARCHITECTURE.md §2). */ + authGuestSilent(locale?: string): Promise; authEmailRequest(email: string, language: string, pwa: boolean): Promise; authEmailLogin(email: string, code: string): Promise; /** Confirm a one-tap email deeplink token: a login returns a session to adopt; a diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 43bd128..84b0e4e 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -180,6 +180,12 @@ export class MockGateway implements GatewayClient { async authGuest(): Promise { return { ...SESSION }; } + async authGuestSilent(): Promise { + // The reconciliation leg the native offline-first e2e drives (via __mock.reconcile): mint the + // server guest the local guest adopts when the network returns. The mock has no kill switch, so + // this is the same guest session as authGuest. + return { ...SESSION }; + } async authEmailRequest(_email: string, _language: string, _pwa: boolean): Promise {} async confirmEmailLink(_token: string): Promise { return { purpose: 'link', status: 'confirmed', session: null }; diff --git a/ui/src/lib/native.ts b/ui/src/lib/native.ts index df77896..7ba5857 100644 --- a/ui/src/lib/native.ts +++ b/ui/src/lib/native.ts @@ -17,9 +17,16 @@ import { clientChannel } from './channel'; export async function initNativeShell(atNavigationRoot: () => boolean): Promise { const ch = clientChannel(); if (ch !== 'android' && ch !== 'ios') return; - const { App } = await import('@capacitor/app'); - App.addListener('backButton', () => { - if (atNavigationRoot()) void App.exitApp(); - else history.back(); - }); + try { + const { App } = await import('@capacitor/app'); + await App.addListener('backButton', () => { + if (atNavigationRoot()) void App.exitApp(); + else history.back(); + }); + } catch { + // No Capacitor bridge behind window.Capacitor — an e2e simulating the native channel in a plain + // browser, or a WebView without the App plugin: the hardware-Back binding is simply unavailable, + // which is harmless off a real device. Never reached on the web (the early return above). + } } + diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 77bf312..ad34d6f 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -61,8 +61,17 @@ export function createTransport(baseUrl: string): GatewayClient { // exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a // dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting // indicator. A successful round-trip marks the gateway reachable; a domain result_code is final. - async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise { - assertOnline(); + async function exec( + messageType: string, + payload: Uint8Array, + signal?: AbortSignal, + opts?: { silent?: boolean; allowOffline?: boolean }, + ): Promise { + // allowOffline lets the background guest-reconciliation call reach the gateway while the app is + // still in auto-offline mode (that call IS the reachability check); every other call honours the + // kill switch. silent suppresses the terminal update overlay on update_required (the caller — the + // reconciliation — swallows it and stays a local guest); foreground calls still raise it. + if (!opts?.allowOffline) assertOnline(); for (let attempt = 0; ; attempt++) { let res; try { @@ -78,8 +87,9 @@ export function createTransport(baseUrl: string): GatewayClient { throw toGatewayError(e); } const err = toGatewayError(e); - // A too-old client turned away on a foreground call raises the terminal update overlay. - if (err.code === UPDATE_REQUIRED) reportUpdateRequired(); + // A too-old client turned away on a foreground call raises the terminal update overlay; a + // silent (background reconciliation) call swallows it and stays a local guest. + if (err.code === UPDATE_REQUIRED && !opts?.silent) reportUpdateRequired(); if (retryable(err.code, messageType) && attempt < MAX_RETRIES) { reportOffline(); await sleep(backoffMs(attempt + 1)); @@ -93,7 +103,7 @@ export function createTransport(baseUrl: string): GatewayClient { // reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts). maintenanceRecovered(); if (res.resultCode && res.resultCode !== 'ok') { - if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired(); + if (res.resultCode === UPDATE_REQUIRED && !opts?.silent) reportUpdateRequired(); throw new GatewayError(res.resultCode); } return res.payload; @@ -145,6 +155,18 @@ export function createTransport(baseUrl: string): GatewayClient { async authGuest(locale) { return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset(), platformSubtype()))); }, + async authGuestSilent(locale) { + // Background reconciliation of a native local guest gaining the network: it runs while the app is + // still in auto-offline mode (allowOffline bypasses the kill switch) and must never raise the + // terminal update overlay (silent) — a too-old client stays a local guest instead of being + // interrupted mid-play (docs/ARCHITECTURE.md §2, the gate×offline rule). + return codec.decodeSession( + await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset(), platformSubtype()), undefined, { + silent: true, + allowOffline: true, + }), + ); + }, async authEmailRequest(email, language, pwa) { await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa)); },