release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby #250

Merged
developer merged 22 commits from development into master 2026-07-13 01:06:35 +00:00
9 changed files with 318 additions and 79 deletions
Showing only changes of commit e077258567 - Show all commits
+12 -2
View File
@@ -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)
+88 -60
View File
@@ -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/<variant>@<version>.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.1D.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/<variant>@<version>.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.
- **EG — 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/<variant>@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/<variant>@<version>.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.
+100
View File
@@ -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<void> {
await page.addInitScript(() => {
(window as unknown as { androidBridge: { postMessage: () => void } }).androidBridge = { postMessage: () => {} };
(window as unknown as { Capacitor: { getPlatform: () => string } }).Capacitor = { getPlatform: () => 'android' };
});
}
// The native cold boot skips the blocking login and lands straight in the lobby as a device-local
// guest in (auto) offline mode: the header carries the offline tint and the tab bar is present with
// no guest sign-in click. Offline the seeded online game (vs Ann) is hidden and Stats is disabled.
async function expectOfflineGuestLobby(page: Page): Promise<void> {
await expect(page.locator('header.nav.offline')).toBeVisible();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
}
// Pick a rack tile by its glyph and drop it on a board square (the pinned-seed rack is all-distinct).
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
await page.locator('.rack .tile', { hasText: glyph }).first().click();
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
}
// typePin clicks the on-screen keypad digits (mirrors hotseat.spec — the pad has no OK button, so the
// 4th digit is the action; wait for the dots to clear so a call right after a previous entry keeps them).
async function typePin(page: Page, digits: string): Promise<void> {
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
}
test.describe('native offline-first', () => {
test('cold-boots to the offline guest lobby, plays local vs_ai, reconciles a server guest online', async ({ page }) => {
await simulateNative(page);
await page.goto('/');
await expectOfflineGuestLobby(page);
// Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary
// comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no
// network is needed — this is the device-local guest playing with zero connectivity.
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
await page.locator('button.tab').nth(0).click();
await page.locator('.variant', { hasText: 'Scrabble' }).click();
await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// The human plays WAY horizontally across the centre (7,5)-(7,7); the local robot replies with a
// real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three.
await placeTile(page, 'W', 7, 5);
await placeTile(page, 'A', 7, 6);
await placeTile(page, 'Y', 7, 7);
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
await page.locator('.make').click();
await expect(async () => {
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
}).toPass({ timeout: 15000 });
// Back to the lobby, 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();
});
});
+5 -3
View File
@@ -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/<variant>.dawg for the mock's session-gated fetchDict
// (network tier) and dict/<variant>@<version>.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,
+64 -4
View File
@@ -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<void> {
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<void> {
} 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(),
};
}
+4
View File
@@ -68,6 +68,10 @@ export interface GatewayClient {
* cosmetic seed for a brand-new account). */
authVK(params: string, displayName: string): Promise<Session>;
authGuest(locale?: string): Promise<Session>;
/** 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<Session>;
authEmailRequest(email: string, language: string, pwa: boolean): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>;
/** Confirm a one-tap email deeplink token: a login returns a session to adopt; a
+6
View File
@@ -180,6 +180,12 @@ export class MockGateway implements GatewayClient {
async authGuest(): Promise<Session> {
return { ...SESSION };
}
async authGuestSilent(): Promise<Session> {
// 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<void> {}
async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
return { purpose: 'link', status: 'confirmed', session: null };
+12 -5
View File
@@ -17,9 +17,16 @@ import { clientChannel } from './channel';
export async function initNativeShell(atNavigationRoot: () => boolean): Promise<void> {
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).
}
}
+27 -5
View File
@@ -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<Uint8Array> {
assertOnline();
async function exec(
messageType: string,
payload: Uint8Array,
signal?: AbortSignal,
opts?: { silent?: boolean; allowOffline?: boolean },
): Promise<Uint8Array> {
// 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));
},