From 1a456c484715a35aeb00c03b495d2fd4f19eccb1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 15:46:22 +0200 Subject: [PATCH] feat(offline): transport kill switch + gate the network-requiring UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline mode was 'fiction': the UI only disabled the Stats tab, but Profile was reachable and a profile save actually hit the server and reported 'saved'. Two layers now: - Transport kill switch (transport.ts): in offline mode every network op — each unary RPC (exec), the live stream (subscribe), the dict/metrics fetches and the reachability probe — is refused before it leaves the device. Offline truly means offline regardless of any UI that slips through. The local (device-only) game path never uses this transport, so it is unaffected. - UI gating: the Profile and Friends tabs (SettingsHub) and the Feedback entry (About) are disabled offline, and the hub falls back to Settings (which holds the online/offline toggle); the lobby Stats tab was already disabled. In-game social/export are already hidden for a vs_ai game. Online unaffected (offlineMode is off): e2e 196. Offline gating is contour-verified (the mock e2e cannot enter offline). --- ui/src/lib/transport.ts | 14 ++++++++++++++ ui/src/screens/About.svelte | 3 ++- ui/src/screens/SettingsHub.svelte | 11 +++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 51bf052..f868d7d 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -12,6 +12,7 @@ import { GatewayError, type GatewayClient } from './client'; import * as codec from './codec'; import { browserOffset } from './profileValidation'; import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; +import { offlineMode } from './offline.svelte'; import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; import { maintenanceRetryMs } from './maintenance'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; @@ -19,6 +20,13 @@ import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry' const MAX_RETRIES = 6; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +// The offline kill switch: in deliberate offline mode every network operation is refused before it +// leaves the device, so offline truly means offline regardless of any UI affordance that slips +// through. The local (device-only) game path never uses this transport, so it is unaffected. +function assertOnline(): void { + if (offlineMode.active) throw new GatewayError('offline'); +} + export function createTransport(baseUrl: string): GatewayClient { const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : ''); const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true }); @@ -32,6 +40,7 @@ export function createTransport(baseUrl: string): GatewayClient { // (it must reject when there is no session, so the watcher keeps waiting rather than reporting up). const reachabilityProbe = async (): Promise => { if (!token) throw new Error('no session'); + assertOnline(); await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() }); }; registerProbe(reachabilityProbe); @@ -43,6 +52,7 @@ export function createTransport(baseUrl: string): GatewayClient { // dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting // indicator. A successful round-trip marks the gateway reachable; a domain result_code is final. async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise { + assertOnline(); for (let attempt = 0; ; attempt++) { let res; try { @@ -82,6 +92,7 @@ export function createTransport(baseUrl: string): GatewayClient { async fetchDict(variant, version, opts) { if (!token) throw new Error('fetchDict: no session'); + assertOnline(); // Low priority so the browser schedules the (large) dictionary behind the game's own // requests — the move eval, state and live events — on a slow link (EDGE). Ignored // where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The @@ -99,6 +110,7 @@ export function createTransport(baseUrl: string): GatewayClient { async reportLocalEval(counts) { if (!token) throw new Error('reportLocalEval: no session'); + assertOnline(); // keepalive so a batch flushed as the app is backgrounded still reaches the edge. const res = await fetch(`${origin}/metrics/local-eval`, { method: 'POST', @@ -319,6 +331,8 @@ export function createTransport(baseUrl: string): GatewayClient { }, subscribe(onEvent, onError) { + // No live stream in offline mode (the kill switch); return an inert unsubscribe. + if (offlineMode.active) return () => {}; const ctrl = new AbortController(); void (async () => { try { diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index 62f3abc..fd653b6 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -2,6 +2,7 @@ import { t } from '../lib/i18n/index.svelte'; import { app } from '../lib/app.svelte'; import { navigate } from '../lib/router.svelte'; + import { offlineMode } from '../lib/offline.svelte'; import { aboutContent } from '../lib/aboutContent'; import { onExternalLinkClick } from '../lib/links'; @@ -35,7 +36,7 @@

{t('about.version', { v: version })}

{#if !(app.profile?.isGuest ?? true)} - {/if} diff --git a/ui/src/screens/SettingsHub.svelte b/ui/src/screens/SettingsHub.svelte index b570fb2..a2934dc 100644 --- a/ui/src/screens/SettingsHub.svelte +++ b/ui/src/screens/SettingsHub.svelte @@ -6,6 +6,7 @@ import Friends from './Friends.svelte'; import About from './About.svelte'; import { app } from '../lib/app.svelte'; + import { offlineMode } from '../lib/offline.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; // The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile / @@ -23,6 +24,12 @@ $effect(() => { if (guest && tab === 'friends') tab = 'settings'; }); + // Offline mode has no network, so the Profile and Friends surfaces (which read/save over the + // network) are disabled — fall back to Settings if the hub is on one (a deep-link or a live flip). + // Settings stays reachable: it holds the online/offline toggle. + $effect(() => { + if (offlineMode.active && (tab === 'profile' || tab === 'friends')) tab = 'settings'; + }); const titleKey: Record = { settings: 'settings.title', @@ -48,11 +55,11 @@ - {#if !guest} - {/if}