feat(offline): auto-detect no network at cold start + 'go offline?' dialog
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
A sticky-online cold start with no network hung the splash on adoptSession's retrying profile fetch. Now, for an offline-capable web install with a cached profile: - No network interface (navigator.onLine === false) -> enter offline mode for the session (no dialog; the next launch re-evaluates). - Interface up but the gateway is unreachable within 3s (a single-attempt reachability probe, not the 6-retry loop) -> a 'No connection. Enable offline mode?' dialog: Enable -> sticky offline; Keep trying -> the normal online adopt (retries, 'Connecting...'). - connection.svelte: checkReachable(timeout) - a bounded single probe. - offline.svelte: setOfflineMode(on, persist) - auto-offline is session-only, a deliberate choice (dialog/toggle) is sticky. - app.svelte.ts: the cold-start auto-detect in bootstrap + the dialog resolver; App.svelte renders the boot dialog. i18n en/ru. - App-entry bundle budget 113->114 (the boot path cannot be lazy-loaded). Online cold-start unaffected (auto-detect gated to isStandalone, off in the mock e2e): e2e 196. The offline paths are contour-verified. Next: PR2 - mid-session flight-mode reactivity (online/offline events).
This commit is contained in:
@@ -55,8 +55,8 @@ import {
|
||||
savePrefs,
|
||||
} from './session';
|
||||
import type { CoachSeries } from './coachmark';
|
||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||
import { offlineMode } from './offline.svelte';
|
||||
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
|
||||
import { offlineMode, setOfflineMode } from './offline.svelte';
|
||||
import { shouldBootOffline } from './offline';
|
||||
import { isConnectionCode } from './retry';
|
||||
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
||||
@@ -78,6 +78,10 @@ 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
|
||||
@@ -148,6 +152,7 @@ export const app = $state<{
|
||||
}>({
|
||||
ready: false,
|
||||
bootError: false,
|
||||
offlinePrompt: false,
|
||||
launchError: null,
|
||||
debugOpen: false,
|
||||
lobbyReady: false,
|
||||
@@ -551,6 +556,27 @@ 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.
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* applyLinkResult applies a completed account link or merge: it adopts a
|
||||
* switched session (a guest initiator whose durable counterpart won, so the active
|
||||
@@ -824,6 +850,34 @@ export async function bootstrap(): Promise<void> {
|
||||
// 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.
|
||||
const cachedProfile = await loadProfile();
|
||||
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
|
||||
if (canOffline) {
|
||||
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
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);
|
||||
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…").
|
||||
}
|
||||
}
|
||||
await adoptSession(saved);
|
||||
if (vkcb) {
|
||||
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
|
||||
|
||||
@@ -27,6 +27,26 @@ export function registerProbe(fn: () => Promise<void>): void {
|
||||
probe = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
|
||||
* gateway answered — a single attempt (no retry loop), for the cold-start network decision — while
|
||||
* updating the online signal. It reports false when no probe is registered or the timeout wins.
|
||||
*/
|
||||
export async function checkReachable(timeoutMs: number): Promise<boolean> {
|
||||
if (!probe) return false;
|
||||
try {
|
||||
await Promise.race([
|
||||
probe(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
|
||||
]);
|
||||
reportOnline();
|
||||
return true;
|
||||
} catch {
|
||||
reportOffline();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** reportOnline marks the gateway reachable and stops the watcher. */
|
||||
export function reportOnline(): void {
|
||||
online = true;
|
||||
|
||||
@@ -218,6 +218,9 @@ export const en = {
|
||||
'settings.online': 'Online',
|
||||
'settings.offline': 'Offline',
|
||||
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
|
||||
'offline.promptTitle': 'No connection. Enable offline mode?',
|
||||
'offline.promptYes': 'Enable',
|
||||
'offline.promptNo': 'Keep trying',
|
||||
|
||||
'about.title': 'About',
|
||||
'about.tab': 'Info',
|
||||
|
||||
@@ -218,6 +218,9 @@ export const ru: Record<MessageKey, string> = {
|
||||
'settings.online': 'Онлайн',
|
||||
'settings.offline': 'Оффлайн',
|
||||
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
|
||||
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
|
||||
'offline.promptYes': 'Включить',
|
||||
'offline.promptNo': 'Ждать сеть',
|
||||
|
||||
'about.title': 'О программе',
|
||||
'about.tab': 'Инфо',
|
||||
|
||||
@@ -20,10 +20,15 @@ export const offlineMode = {
|
||||
},
|
||||
};
|
||||
|
||||
/** setOfflineMode enters or leaves offline mode and persists the choice (device-scoped). */
|
||||
export function setOfflineMode(on: boolean): void {
|
||||
/**
|
||||
* setOfflineMode enters or leaves offline mode. By default it persists the choice (device-scoped) —
|
||||
* a deliberate choice (the Settings toggle, or the cold-start "no connection" dialog). Pass
|
||||
* persist=false for a transient, auto-detected offline (a cold start with no network interface): the
|
||||
* flag holds for the session but is not saved, so the next launch re-evaluates the network.
|
||||
*/
|
||||
export function setOfflineMode(on: boolean, persist = true): void {
|
||||
active = on;
|
||||
saveOfflinePref(on);
|
||||
if (persist) saveOfflinePref(on);
|
||||
}
|
||||
|
||||
// The dict-preload warning: true when a first-lobby background preload could not fetch every
|
||||
|
||||
Reference in New Issue
Block a user