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
+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));
},