2d1fadb50c
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.
197 lines
8.3 KiB
TypeScript
197 lines
8.3 KiB
TypeScript
// The reactive net-state store (O2): the single source of truth for the detected
|
|
// connectivity/version state, running the pure reducer (netstate.ts, O1) over a `$state` snapshot and
|
|
// owning the side-effects the reducer asks for — the probe/recovery watcher, the OS-signal listeners
|
|
// and the offline/online toast. connection.svelte.ts and offline.svelte.ts are now thin derived shims
|
|
// over this module, so their ~40 consumers keep reading `connection.online` / `offlineMode.active`
|
|
// unchanged while a single machine drives them underneath.
|
|
//
|
|
// The store is a leaf module: it never imports app.svelte.ts. The pieces that need app context — the
|
|
// smart recovery (reconcile-or-reachability) and the toast text (i18n) — are injected via
|
|
// register*() hooks the bootstrap wires, which also breaks what would otherwise be an import cycle.
|
|
|
|
import { INITIAL, reduce, type Effect, type NetConfig, type NetEvent, type NetSnapshot } from './netstate';
|
|
import { backoffMs } from './retry';
|
|
|
|
const isMock = import.meta.env.MODE === 'mock';
|
|
|
|
// Production hysteresis tuning (owner-confirmed). k = 3 consecutive probe failures ⇒ offline (a single
|
|
// blip is one failure, so it rides out in `connecting`); debounceMs is the wall-clock backstop for a
|
|
// slow, spaced-out failure sequence. Both sit on top of the transport's own retry/backoff.
|
|
const cfg: NetConfig = { k: 3, debounceMs: 15000 };
|
|
// While offline, re-check reachability on this cadence (mirrors the old app.svelte.ts recovery poll).
|
|
const OFFLINE_POLL_MS = 4000;
|
|
|
|
let snap = $state<NetSnapshot>({ ...INITIAL });
|
|
|
|
/** netState exposes the reactive detected state; read the getters in markup / `$derived`. */
|
|
export const netState = {
|
|
/** state is the raw machine state. */
|
|
get state() {
|
|
return snap.state;
|
|
},
|
|
/** online is true only when the gateway is reachable and the version accepted (full features). */
|
|
get online() {
|
|
return snap.state === 'online';
|
|
},
|
|
/** connecting is true while a call/probe is failing but the anti-flap window has not elapsed. */
|
|
get connecting() {
|
|
return snap.state === 'connecting';
|
|
},
|
|
/** offline is true in either offline state (the kill switch / blue chrome / local-only lobby). */
|
|
get offline() {
|
|
return snap.state === 'offlineNoNetwork' || snap.state === 'offlineVersionLocked';
|
|
},
|
|
/** versionLocked is true when the client is below the hard minimum version (the Update notice). */
|
|
get versionLocked() {
|
|
return snap.state === 'offlineVersionLocked';
|
|
},
|
|
};
|
|
|
|
// Injected hooks (dependency inversion — see the module comment).
|
|
let probeFn: (() => Promise<void>) | null = null;
|
|
let recoverFn: (() => Promise<void>) | null = null;
|
|
let toastFn: ((toast: 'offline' | 'online') => void) | null = null;
|
|
let nudgeFn: (() => void) | null = null;
|
|
|
|
/** registerProbe installs the low-level reachability probe (a cheap authenticated read); the transport
|
|
* wires it, and checkReachable / the smart recovery use it. It should reject when there is no session. */
|
|
export function registerProbe(fn: () => Promise<void>): void {
|
|
probeFn = fn;
|
|
}
|
|
|
|
/** registerRecovery installs the smart recovery the watcher runs while connecting/offline: reconcile a
|
|
* server guest when there is no session (native), otherwise a reachability check. It resolves when the
|
|
* gateway is reachable and rejects to stay offline. The bootstrap wires it. */
|
|
export function registerRecovery(fn: () => Promise<void>): void {
|
|
recoverFn = fn;
|
|
}
|
|
|
|
/** registerNetToast installs the offline/online toast renderer (i18n text lives in app scope). */
|
|
export function registerNetToast(fn: (toast: 'offline' | 'online') => void): void {
|
|
toastFn = fn;
|
|
}
|
|
|
|
/** registerNetNudge installs the soft "update available" latch the setNudge effect fires. It runs only
|
|
* when the machine accepts a versionRecommended from online (O1), so the nudge never shows offline. */
|
|
export function registerNetNudge(fn: () => void): void {
|
|
nudgeFn = fn;
|
|
}
|
|
|
|
// The probe/recovery watcher: one self-rescheduling timer whose cadence follows the current state —
|
|
// exponential backoff while `connecting`, a fixed poll while `offlineNoNetwork`, and idle otherwise. It
|
|
// is inert under the mock (no real transport); the e2e drives transitions through the window hooks.
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
let attempt = 0;
|
|
let probing = false;
|
|
|
|
function stopTimer(): void {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
}
|
|
|
|
function arm(delayMs: number): void {
|
|
if (isMock) return;
|
|
stopTimer();
|
|
timer = setTimeout(runProbe, delayMs);
|
|
}
|
|
|
|
async function runProbe(): Promise<void> {
|
|
timer = null;
|
|
const st = snap.state;
|
|
// Nothing to check when online, and the version lock only exits on an app update (a fresh boot).
|
|
if (st === 'online' || st === 'offlineVersionLocked') return;
|
|
if (probing || !recoverFn) return;
|
|
probing = true;
|
|
try {
|
|
await recoverFn();
|
|
emit('probeOk');
|
|
} catch {
|
|
emit('probeFailed');
|
|
// Reschedule by the (possibly just-changed) state: keep backing off while connecting, poll while
|
|
// offline; stop once online / version-locked (the guard at the top of the next run).
|
|
if (snap.state === 'connecting') arm(backoffMs(Math.min(++attempt, 6)));
|
|
else if (snap.state === 'offlineNoNetwork') arm(OFFLINE_POLL_MS);
|
|
} finally {
|
|
probing = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* checkReachable runs the registered reachability probe once, bounded by timeoutMs, and reports whether
|
|
* the gateway answered — a single attempt for the cold-start / recovery decision. It reports false when
|
|
* no probe is registered or the timeout wins.
|
|
*/
|
|
export async function checkReachable(timeoutMs: number): Promise<boolean> {
|
|
if (!probeFn) return false;
|
|
try {
|
|
await Promise.race([
|
|
probeFn(),
|
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
|
|
]);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** emit applies one event to the machine (stamping it with the current time — the reducer chose
|
|
* time-on-the-event) and runs the resulting effects. It is the single entry point every signal source
|
|
* funnels through. */
|
|
export function emit(type: NetEvent): void {
|
|
const { next, effects } = reduce(snap, { type, at: Date.now() }, cfg);
|
|
snap = next;
|
|
for (const effect of effects) applyEffect(effect);
|
|
}
|
|
|
|
function applyEffect(effect: Effect): void {
|
|
switch (effect.kind) {
|
|
case 'toast':
|
|
toastFn?.(effect.toast);
|
|
break;
|
|
case 'startProbe':
|
|
// Kick the watcher from the top of the backoff (entering connecting, or an osOnline hint).
|
|
attempt = 0;
|
|
arm(backoffMs(1));
|
|
break;
|
|
case 'showVersionNotice':
|
|
// The Update/Play-offline notice (UpdateOverlay.svelte) reads netState.versionLocked directly, so
|
|
// this effect needs no side-effect here — the state transition is the signal.
|
|
break;
|
|
case 'setNudge':
|
|
// Latch the soft "update available" nudge (update.svelte.ts, wired via registerNetNudge). Fires
|
|
// only from online (O1), so the nudge is never raised while offline / version-locked.
|
|
nudgeFn?.();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* bootOffline drives the machine straight into offlineNoNetwork for a known-offline launch — a native
|
|
* device-local guest with no server session, or a web cached-session launch the reachability check
|
|
* could not confirm — and arms the recovery poll. It bypasses the connecting hysteresis on purpose:
|
|
* boot is not a mid-session flap, so there is nothing to ride out. Pass kickNow to start the recovery
|
|
* poll immediately (the native reconcile) rather than after the first interval.
|
|
*/
|
|
export function bootOffline(kickNow = false): void {
|
|
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
|
|
arm(kickNow ? 0 : OFFLINE_POLL_MS);
|
|
}
|
|
|
|
/** initNetSignals wires the web connectivity hints (navigator online/offline) to the machine. The
|
|
* native @capacitor/network hint is wired separately in native.ts. A no-op under SSR and the mock (the
|
|
* e2e drives connectivity through the window hooks). */
|
|
export function initNetSignals(): void {
|
|
if (typeof window === 'undefined' || isMock) return;
|
|
window.addEventListener('offline', () => emit('osOffline'));
|
|
window.addEventListener('online', () => emit('osOnline'));
|
|
}
|
|
|
|
/** resetNetState returns the machine to online and stops the watcher (e.g. on logout). */
|
|
export function resetNetState(): void {
|
|
stopTimer();
|
|
attempt = 0;
|
|
snap = { state: 'online', fails: 0, connectingSince: 0 };
|
|
}
|