a57fd355ba
Introduce a minimum-supported-client gate so a future incompatible wire change can turn away installed builds too old to speak it, cleanly, instead of letting them crash on decode. It rides the outermost stable layer (an HTTP header), never the FlatBuffers payload. Gateway: - New internal/clientver: dependency-free parse + compare of the leading MAJOR.MINOR.PATCH (a git-describe suffix is tolerated). - GATEWAY_MIN_CLIENT_VERSION config (empty => gate dormant; validated at load). - connectsrv checks the X-Client-Version header before decoding the payload: Execute returns result_code="update_required" (before the registry lookup), Subscribe returns FailedPrecondition. It fails open on an absent or garbled header — the header is a client-controlled compatibility signal, not an access control. Client: - Attach X-Client-Version on every call. - A terminal update.svelte.ts store + a non-dismissable UpdateOverlay (native opens VITE_STORE_URL, web reloads); retry.ts maps FailedPrecondition to the update_required sentinel; a mock __update hook drives the e2e. Wire-additive and contour-safe: no FBS/proto regen, no schema migration; the gate stays dormant until GATEWAY_MIN_CLIENT_VERSION is deliberately set, so web / VK / Telegram behaviour is unchanged. The silent reconciliation seam is deferred to the offline-first work (its only caller). Tests: Go clientver/config/connectsrv gate tests, retry.test.ts, Playwright update.spec.ts.
231 lines
8.8 KiB
Svelte
231 lines
8.8 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { cubicOut } from 'svelte/easing';
|
|
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
|
|
import { router, type RouteName } from './lib/router.svelte';
|
|
import { t } from './lib/i18n/index.svelte';
|
|
import { initNativeShell } from './lib/native';
|
|
import Toast from './components/Toast.svelte';
|
|
import Splash from './components/Splash.svelte';
|
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
|
import Coachmark from './components/Coachmark.svelte';
|
|
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
|
|
import UpdateOverlay from './components/UpdateOverlay.svelte';
|
|
import DebugPanel from './components/DebugPanel.svelte';
|
|
import Login from './screens/Login.svelte';
|
|
import Lobby from './screens/Lobby.svelte';
|
|
import NewGame from './screens/NewGame.svelte';
|
|
import SettingsHub from './screens/SettingsHub.svelte';
|
|
import Stats from './screens/Stats.svelte';
|
|
import Confirm from './screens/Confirm.svelte';
|
|
import Game from './game/Game.svelte';
|
|
import CommsHub from './game/CommsHub.svelte';
|
|
import Feedback from './screens/Feedback.svelte';
|
|
import Blocked from './screens/Blocked.svelte';
|
|
import AccountDeleted from './screens/AccountDeleted.svelte';
|
|
import BootError from './screens/BootError.svelte';
|
|
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
|
|
|
|
onMount(() => {
|
|
// Tell the index.html boot guard that startup completed, so its reactive net does not raise the
|
|
// unsupported-engine screen on a healthy boot — it only fires when an uncaught error occurred
|
|
// AND this flag never sets (a bundle that failed to parse, or an unforeseen incompatibility).
|
|
void bootstrap().then(() => {
|
|
(window as unknown as { __booted?: boolean }).__booted = true;
|
|
});
|
|
// Native shell only: wire the Android hardware Back button. No-op — and pulls no
|
|
// @capacitor/app — on web/Telegram/VK, where initNativeShell early-returns. At the
|
|
// navigation root (lobby/login) Back exits the app; otherwise it steps history back.
|
|
void initNativeShell(() => routeDepth(router.route.name) === 0);
|
|
});
|
|
|
|
// The lobby is the cold-start landing (an empty hash and Telegram launch params both parse
|
|
// to it). The tile loading splash overlays it until its first load settles; a deep-link to
|
|
// another screen is not covered.
|
|
const routeIsLobby = $derived(router.route.name === 'lobby');
|
|
|
|
// Screen transitions: the lobby is the navigation root. Entering a screen from the
|
|
// lobby slides it in from the right (forward); returning to the lobby slides the
|
|
// screen out to the right and reveals the lobby (back). Transitions are local, so
|
|
// they do not play on the initial mount, and collapse to nothing under reduce-motion.
|
|
// Slide direction by route depth: going deeper (lobby → game → chat/check) slides forward,
|
|
// returning to a shallower screen slides back. A plain "lobby is back" rule slid the chat/check
|
|
// back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it
|
|
// only after the transition has captured its sign).
|
|
function routeDepth(name: RouteName): number {
|
|
if (name === 'gameChat' || name === 'gameCheck' || name === 'feedback') return 2;
|
|
if (name === 'lobby' || name === 'login') return 0;
|
|
return 1;
|
|
}
|
|
const curDepth = $derived(routeDepth(router.route.name));
|
|
let prevDepth = $state(routeDepth(router.route.name));
|
|
const dir = $derived(curDepth < prevDepth ? 'back' : 'forward');
|
|
$effect(() => {
|
|
prevDepth = curDepth;
|
|
});
|
|
const enterSign = $derived(dir === 'forward' ? 1 : -1);
|
|
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
|
|
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
|
|
const animMs = $derived(app.reduceMotion ? 0 : 260);
|
|
|
|
// slideX slides a pane horizontally by a full width. sign>0 enters from / exits to
|
|
// the right; sign<0 the left. Percentage keeps it viewport-relative without reading
|
|
// innerWidth, and the .router clips the off-screen pane.
|
|
function slideX(_node: Element, { duration, sign }: { duration: number; sign: number }) {
|
|
return {
|
|
duration,
|
|
easing: cubicOut,
|
|
css: (tt: number) => `transform: translateX(${(1 - tt) * sign * 100}%)`,
|
|
};
|
|
}
|
|
</script>
|
|
|
|
{#if !app.ready}
|
|
<!-- On a cold lobby open the tile splash (below) covers bootstrap too; a deep-link to
|
|
another screen keeps the plain text splash while bootstrap runs. -->
|
|
{#if !routeIsLobby}
|
|
<div class="splash">{t('common.loading')}</div>
|
|
{/if}
|
|
{:else if app.launchError}
|
|
<!-- The /telegram/ entry without sign-in data: a compact, shareable diagnostic screen instead
|
|
of bouncing to the marketing landing (also the probe for the empty-initData failure on
|
|
some Android clients). -->
|
|
<TelegramLaunchError />
|
|
{:else if app.bootError}
|
|
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
|
|
show the retry screen instead of falling back to the web login. -->
|
|
<BootError />
|
|
{:else if app.accountDeleted}
|
|
<AccountDeleted />
|
|
{:else if app.blocked}
|
|
<Blocked />
|
|
{:else}
|
|
<div class="router">
|
|
{#key routeKey}
|
|
<div class="pane" in:slideX={{ duration: animMs, sign: enterSign }} out:slideX={{ duration: animMs, sign: leaveSign }}>
|
|
{#if router.route.name === 'login'}
|
|
<Login />
|
|
{:else if router.route.name === 'new'}
|
|
<NewGame />
|
|
{:else if router.route.name === 'game'}
|
|
<Game id={router.route.params.id} />
|
|
{:else if router.route.name === 'gameChat'}
|
|
<CommsHub id={router.route.params.id} initialTab="chat" />
|
|
{:else if router.route.name === 'gameCheck'}
|
|
<CommsHub id={router.route.params.id} initialTab="dictionary" />
|
|
{:else if router.route.name === 'profile'}
|
|
<SettingsHub initialTab="profile" />
|
|
{:else if router.route.name === 'settings'}
|
|
<SettingsHub initialTab="settings" />
|
|
{:else if router.route.name === 'about'}
|
|
<SettingsHub initialTab="about" />
|
|
{:else if router.route.name === 'friends'}
|
|
<SettingsHub initialTab="friends" />
|
|
{:else if router.route.name === 'wallet'}
|
|
<SettingsHub initialTab="wallet" />
|
|
{:else if router.route.name === 'feedback'}
|
|
<Feedback />
|
|
{:else if router.route.name === 'stats'}
|
|
<Stats />
|
|
{:else if router.route.name === 'confirm'}
|
|
<Confirm token={router.route.params.token} />
|
|
{:else}
|
|
<Lobby />
|
|
{/if}
|
|
</div>
|
|
{/key}
|
|
</div>
|
|
{/if}
|
|
|
|
<Toast />
|
|
<StaleInviteModal />
|
|
<WelcomeRedeemModal />
|
|
<Coachmark />
|
|
<MaintenanceOverlay />
|
|
<UpdateOverlay />
|
|
|
|
<!-- 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}
|
|
<Splash />
|
|
{/if}
|
|
|
|
{#if app.debugOpen}
|
|
<DebugPanel />
|
|
{/if}
|
|
|
|
<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 {
|
|
height: 100%;
|
|
display: grid;
|
|
place-items: center;
|
|
color: var(--text-muted);
|
|
}
|
|
.router {
|
|
position: relative;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
.pane {
|
|
position: absolute;
|
|
inset: 0;
|
|
}
|
|
</style>
|