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