feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s

Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
This commit is contained in:
Ilia Denisov
2026-07-13 01:44:28 +02:00
parent 09e05eef18
commit 2d1fadb50c
55 changed files with 2065 additions and 976 deletions
+55 -131
View File
@@ -56,8 +56,10 @@ import {
} from './session';
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 { bootOffline, emit, initNetSignals, registerNetNudge, registerNetToast, registerRecovery } from './netstate.svelte';
import { latchUpdateNudge } from './update.svelte';
import { clearOfflinePref } from './offline';
import { initNativeNetwork } from './native';
import { clientChannel } from './channel';
import { localGuestId } from './localguest';
import { isConnectionCode } from './retry';
@@ -80,10 +82,6 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
bootError: boolean;
/** True while the cold-start "no connection — go offline?" dialog is up (the reachability check
* timed out with the network interface reportedly online). App.svelte renders it over the splash;
* bootstrap awaits the choice via resolveOfflinePrompt. */
offlinePrompt: boolean;
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
@@ -160,7 +158,6 @@ export const app = $state<{
}>({
ready: false,
bootError: false,
offlinePrompt: false,
launchError: null,
debugOpen: false,
lobbyReady: false,
@@ -574,82 +571,25 @@ async function adoptSession(s: Session): Promise<void> {
void refreshNotifications();
}
// The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice
// here; App.svelte's dialog buttons call resolveOfflinePrompt.
// The cold-start reachability probe budget: how long the boot waits for the gateway to answer before
// launching from the cache into the implicit offline lobby (offline is auto-detected now — no dialog).
const BOOT_REACHABILITY_TIMEOUT_MS = 3000;
let offlinePromptResolve: ((goOffline: boolean) => void) | null = null;
/** resolveOfflinePrompt answers the cold-start "no connection — go offline?" dialog (App.svelte). */
export function resolveOfflinePrompt(goOffline: boolean): void {
app.offlinePrompt = false;
const resolve = offlinePromptResolve;
offlinePromptResolve = null;
resolve?.(goOffline);
}
/** promptOfflineChoice raises the dialog and resolves to the player's choice (true = go offline). */
function promptOfflineChoice(): Promise<boolean> {
app.offlinePrompt = true;
return new Promise<boolean>((resolve) => {
offlinePromptResolve = resolve;
});
}
/**
* initNetworkReactivity reacts to mid-session network changes (e.g. the player toggling flight mode):
* losing the network interface enters offline mode automatically (session-only); regaining it
* verifies the gateway is really reachable (an interface being up does not guarantee it) and returns
* to online — but only if the offline was auto. A deliberate offline (the Settings toggle or the
* cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no
* battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly).
* recover is the smart recovery the net-state store's watcher runs while connecting/offline: a
* session-less native local guest reconciles a server guest (the reconcile attempt IS the reachability
* probe — it needs no prior session), while any cached-session launch does a cheap authenticated read.
* It resolves when the gateway is reachable (the store then heals to online) and rejects to stay offline.
* bootstrap wires it to the store, which owns the poll/backoff timer and the OS-signal listeners.
*/
// While in auto-offline, poll for the network to return so the app comes back online — robust to the
// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it
// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the
// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in
// auto-offline (a deliberate offline is the player's choice) and stops on returning online.
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
export function scheduleRecovery(delayMs: number): void {
if (recoveryTimer) {
clearTimeout(recoveryTimer);
recoveryTimer = null;
async function recover(): Promise<void> {
const channel = clientChannel();
if ((channel === 'android' || channel === 'ios') && !app.session) {
await reconcileServerGuest();
if (!app.session) throw new Error('still a local guest'); // reconcile did not adopt — stay offline
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) {
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);
}
export function initNetworkReactivity(): void {
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
window.addEventListener('offline', () => {
if (!offlineMode.active) {
setOfflineMode(true, false); // auto (session)
scheduleRecovery(4000); // poll for the network to return
}
});
// The online event, when it fires, just kicks an immediate reachability check; the poll above is
// the reliable fallback for platforms where it does not fire.
window.addEventListener('online', () => {
if (offlineMode.active && offlineMode.auto) scheduleRecovery(0);
});
if (!(await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) throw new Error('gateway unreachable');
}
// A concurrency guard: the boot kick, the recovery poll and the online event can all try to reconcile
@@ -672,9 +612,10 @@ async function reconcileServerGuest(): Promise<void> {
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);
// The gateway answered, so heal the machine to online BEFORE adopting: adoptSession's profile fetch
// and live stream ride the normal (online) transport, which the offline kill switch would otherwise
// refuse. emit('callOk') is idempotent when already online (a re-entrant recover after adoption).
emit('callOk');
await adoptSession(s);
} catch {
// A too-old client (update_required, swallowed — stay a local guest, no overlay) or an unreachable
@@ -857,6 +798,9 @@ export async function bootstrap(): Promise<void> {
// has no real backend). Deliberately before any await, so even an early-return launch path
// (e.g. the Telegram launch-error screen) still counts as an app open.
if (import.meta.env.MODE !== 'mock') startLocalEvalMetrics();
// One-time cleanup of the retired deliberate-offline preference (offline is implicit now — the key is
// never read again). Runs for every channel, before the Mini-App branches return.
clearOfflinePref();
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
@@ -966,37 +910,27 @@ export async function bootstrap(): Promise<void> {
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
// and skipped in the mock build; see lib/pwa.svelte.
registerServiceWorker();
// React to mid-session network changes (e.g. flight mode) for the rest of the session.
initNetworkReactivity();
// Deliberate offline mode carried over from a prior session: with no network the session adoption
// + profile fetch below would hang the splash, so skip them and launch straight from the cached
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
// with no cached profile is unreachable: enabling offline requires a prior online session.)
if (offlineMode.active) {
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
gateway.setToken(offlineSession!.token);
app.session = offlineSession!;
app.profile = offlineProfile!;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
}
// Wire the net-state machine's signal sources for the rest of the session: the OS connectivity hints
// (web navigator + native @capacitor/network), the smart recovery the store's watcher runs, and the
// offline/online toast. Reached only on web / native — the Telegram/VK branches returned above, so
// those channels are never fed an offline signal and the machine stays inert (always online) there.
registerNetToast((toast) => showToast(t(toast === 'offline' ? 'net.offline' : 'net.online')));
registerNetNudge(latchUpdateNudge);
registerRecovery(recover);
initNetSignals();
void initNativeNetwork();
const saved = await loadSession();
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
// from VK; capture and clear it here (it needs the restored session to link against).
const vkcb = pendingVKLink();
if (saved) {
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when
// the network is down: a device with no network interface goes offline for the session (no
// dialog); an interface that is up but cannot reach the gateway within a short window asks the
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it.
// Cold-start network auto-detect. With a cached profile to fall back on and no pending VK-link, do
// not hang on adoptSession's retrying profile fetch when the network is down: if the interface is
// down, or the gateway does not answer within a short window, launch straight from the cache into
// the implicit offline lobby (no dialog — offline is detected now). The store's recovery poll heals
// to online when the network returns. Web-only reaches here (the Mini-App branches returned above),
// so isStandalone gates it.
const cachedProfile = await loadProfile();
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
if (canOffline) {
@@ -1004,21 +938,12 @@ export async function bootstrap(): Promise<void> {
gateway.setToken(saved.token);
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
if (!reachable) {
// No network interface → go offline for the session (no dialog); interface up but the
// gateway is unreachable → ambiguous, so ask.
const goOffline = interfaceOffline ? true : await promptOfflineChoice();
if (goOffline) {
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
setOfflineMode(true, !interfaceOffline);
if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return
app.session = saved;
app.profile = cachedProfile;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
// "Нет" — keep trying online: fall through to adoptSession (it retries; the connection
// watcher shows "Connecting…").
bootOffline();
app.session = saved;
app.profile = cachedProfile;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
}
await adoptSession(saved);
@@ -1031,15 +956,14 @@ export async function bootstrap(): Promise<void> {
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.
// Native offline-first cold boot: no cached server session, so enter as a device-local guest in the
// implicit offline lobby and land straight there — never /login. bootOffline(true) drops into offline
// and kicks the recovery poll now; recover() mints + adopts a server guest and heals to online once
// the gateway is reachable, and 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);
bootOffline(true); // offline-first + kick reconciliation now, then poll until the network returns
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');
}
@@ -1424,9 +1348,9 @@ 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.
// Drive the native offline-first reconciliation from the e2e: the net-state store's recovery poll that
// fires it in a real build is inert under the mock, so the spec calls this to simulate "the network
// returned" and prove a server guest is minted + adopted and the machine heals to online.
(window as unknown as { __native?: { reconcile(): void } }).__native = {
reconcile: () => void reconcileServerGuest(),
};