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:
@@ -20,12 +20,12 @@ import { join } from 'node:path';
|
|||||||
const DIST = 'dist';
|
const DIST = 'dist';
|
||||||
|
|
||||||
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
||||||
// wiring, to 112 for the PWA install feature, then to 113 for the offline-mode wiring: the
|
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, then to 114 for
|
||||||
// dictionary-preload trigger and the offline-state reactives live in the app entry (the Header
|
// the offline auto-detect: the cold-start reachability check and the "no connection" dialog live in
|
||||||
// reads them, the lobby/profile screens fire the trigger), while the heavy parts — the dict
|
// the boot path (app.svelte.ts / the App shell), which cannot be lazy-loaded. The heavy parts — the
|
||||||
// loader, the move generator and the preload orchestration — stay in lazy chunks. Scoped CSS
|
// dict loader, the move generator and the preload orchestration — still stay in lazy chunks. Scoped
|
||||||
// lands in the CSS chunk, not this JS budget.
|
// CSS lands in the CSS chunk, not this JS budget.
|
||||||
const BUDGET = { app: 113, shared: 30, landing: 5 };
|
const BUDGET = { app: 114, shared: 30, landing: 5 };
|
||||||
|
|
||||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||||
|
|||||||
+59
-1
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { app, bootstrap } from './lib/app.svelte';
|
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
|
||||||
import { router, type RouteName } from './lib/router.svelte';
|
import { router, type RouteName } from './lib/router.svelte';
|
||||||
import { t } from './lib/i18n/index.svelte';
|
import { t } from './lib/i18n/index.svelte';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
@@ -136,6 +136,21 @@
|
|||||||
<Coachmark />
|
<Coachmark />
|
||||||
<MaintenanceOverlay />
|
<MaintenanceOverlay />
|
||||||
|
|
||||||
|
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
|
||||||
|
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
|
||||||
|
keep trying to connect. bootstrap awaits this choice (app.offlinePrompt). -->
|
||||||
|
{#if app.offlinePrompt}
|
||||||
|
<div class="offprompt" role="dialog" aria-modal="true">
|
||||||
|
<div class="card">
|
||||||
|
<p class="msg">{t('offline.promptTitle')}</p>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="opt primary" onclick={() => resolveOfflinePrompt(true)}>{t('offline.promptYes')}</button>
|
||||||
|
<button class="opt" onclick={() => resolveOfflinePrompt(false)}>{t('offline.promptNo')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||||
<Splash />
|
<Splash />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -145,6 +160,49 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* The cold-start "no connection" dialog — a centred card over a scrim, above the splash. */
|
||||||
|
.offprompt {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: var(--pad);
|
||||||
|
}
|
||||||
|
.offprompt .card {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 320px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.offprompt .msg {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.offprompt .acts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.offprompt .opt {
|
||||||
|
padding: 11px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.offprompt .opt.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
.splash {
|
.splash {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ import {
|
|||||||
savePrefs,
|
savePrefs,
|
||||||
} from './session';
|
} from './session';
|
||||||
import type { CoachSeries } from './coachmark';
|
import type { CoachSeries } from './coachmark';
|
||||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
|
||||||
import { offlineMode } from './offline.svelte';
|
import { offlineMode, setOfflineMode } from './offline.svelte';
|
||||||
import { shouldBootOffline } from './offline';
|
import { shouldBootOffline } from './offline';
|
||||||
import { isConnectionCode } from './retry';
|
import { isConnectionCode } from './retry';
|
||||||
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
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
|
* 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. */
|
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
|
||||||
bootError: boolean;
|
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
|
/** 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 carried no sign-in data (empty initData). App.svelte then renders the compact
|
||||||
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
||||||
@@ -148,6 +152,7 @@ export const app = $state<{
|
|||||||
}>({
|
}>({
|
||||||
ready: false,
|
ready: false,
|
||||||
bootError: false,
|
bootError: false,
|
||||||
|
offlinePrompt: false,
|
||||||
launchError: null,
|
launchError: null,
|
||||||
debugOpen: false,
|
debugOpen: false,
|
||||||
lobbyReady: false,
|
lobbyReady: false,
|
||||||
@@ -551,6 +556,27 @@ async function adoptSession(s: Session): Promise<void> {
|
|||||||
void refreshNotifications();
|
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
|
* applyLinkResult applies a completed account link or merge: it adopts a
|
||||||
* switched session (a guest initiator whose durable counterpart won, so the active
|
* 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).
|
// from VK; capture and clear it here (it needs the restored session to link against).
|
||||||
const vkcb = pendingVKLink();
|
const vkcb = pendingVKLink();
|
||||||
if (saved) {
|
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);
|
await adoptSession(saved);
|
||||||
if (vkcb) {
|
if (vkcb) {
|
||||||
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
|
// 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;
|
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. */
|
/** reportOnline marks the gateway reachable and stops the watcher. */
|
||||||
export function reportOnline(): void {
|
export function reportOnline(): void {
|
||||||
online = true;
|
online = true;
|
||||||
|
|||||||
@@ -218,6 +218,9 @@ export const en = {
|
|||||||
'settings.online': 'Online',
|
'settings.online': 'Online',
|
||||||
'settings.offline': 'Offline',
|
'settings.offline': 'Offline',
|
||||||
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
|
'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.title': 'About',
|
||||||
'about.tab': 'Info',
|
'about.tab': 'Info',
|
||||||
|
|||||||
@@ -218,6 +218,9 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'settings.online': 'Онлайн',
|
'settings.online': 'Онлайн',
|
||||||
'settings.offline': 'Оффлайн',
|
'settings.offline': 'Оффлайн',
|
||||||
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
|
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
|
||||||
|
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
|
||||||
|
'offline.promptYes': 'Включить',
|
||||||
|
'offline.promptNo': 'Ждать сеть',
|
||||||
|
|
||||||
'about.title': 'О программе',
|
'about.title': 'О программе',
|
||||||
'about.tab': 'Инфо',
|
'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;
|
active = on;
|
||||||
saveOfflinePref(on);
|
if (persist) saveOfflinePref(on);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The dict-preload warning: true when a first-lobby background preload could not fetch every
|
// The dict-preload warning: true when a first-lobby background preload could not fetch every
|
||||||
|
|||||||
Reference in New Issue
Block a user