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:
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user