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}