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.
This commit is contained in:
Ilia Denisov
2026-07-12 18:03:52 +02:00
parent a035edfb54
commit e077258567
9 changed files with 318 additions and 79 deletions
+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.