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
|
||||
|
||||
Reference in New Issue
Block a user