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:
+88
-60
@@ -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.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/<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.
|
||||
- **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/<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.
|
||||
|
||||
Reference in New Issue
Block a user