diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 735c3ff..e5cc082 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -13,6 +13,7 @@ import { navigate } from '../lib/router.svelte'; import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; + import { offlineMode } from '../lib/offline.svelte'; import { GatewayError } from '../lib/client'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; @@ -52,6 +53,11 @@ // screen calls the game-loop methods (state/history/submit/pass/exchange/resign/hint/evaluate/ // draft) through it, so the same UI drives a local vs_ai game and an online one alike. const source = $derived(gameSource(id)); + // A local (offline) game plays entirely on-device, so it is always ready; an online game needs a + // live connection — and offline mode's kill switch refuses its calls — so its network actions are + // disabled while disconnected or in offline mode (which also stops the "something went wrong" + // toasts a blocked call would otherwise raise). + const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active)); // Unsubscribes from a local game's robot-reply events (offline only; null for a network game). let localUnsub: (() => void) | null = null; @@ -1481,19 +1487,19 @@ /> {#if !gameOver && placement.pending.length > 0 && !recallOverRack} - + {/if} {/snippet} {#snippet controlButtons()} - 🛟{#if hintCount > 0}{hintCount}{/if} @@ -1551,7 +1557,7 @@ (resignOpen = false)}>
- +
{/if} @@ -1563,7 +1569,7 @@ @@ -1571,7 +1577,7 @@ diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 77ef40c..49ab6aa 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -304,7 +304,9 @@ export function handleError(err: unknown): void { void enterBlocked(); return; } - if (isConnectionCode(code) || !connection.online) return; + // A blocked call in offline mode ('offline', the transport kill switch) is expected, not an error + // to surface — the offline chrome already signals the state; never a red toast. + if (isConnectionCode(code) || code === 'offline' || !connection.online) return; haptic('error'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); } @@ -577,6 +579,53 @@ function promptOfflineChoice(): Promise { }); } +/** + * initNetworkReactivity reacts to mid-session network changes (e.g. the player toggling flight mode): + * losing the network interface enters offline mode automatically (session-only); regaining it + * verifies the gateway is really reachable (an interface being up does not guarantee it) and returns + * to online — but only if the offline was auto. A deliberate offline (the Settings toggle or the + * cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no + * battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly). + */ +// While in auto-offline, poll for the network to return so the app comes back online — robust to the +// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it +// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the +// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in +// auto-offline (a deliberate offline is the player's choice) and stops on returning online. +let recoveryTimer: ReturnType | null = null; +export function scheduleRecovery(delayMs: number): void { + if (recoveryTimer) { + clearTimeout(recoveryTimer); + recoveryTimer = null; + } + if (typeof window === 'undefined' || !offlineMode.active || !offlineMode.auto) return; + recoveryTimer = setTimeout(async () => { + recoveryTimer = null; + if (!offlineMode.active || !offlineMode.auto) return; + const interfaceUp = typeof navigator === 'undefined' || navigator.onLine; + if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) { + if (offlineMode.active && offlineMode.auto) setOfflineMode(false); + return; + } + scheduleRecovery(4000); + }, delayMs); +} + +export function initNetworkReactivity(): void { + if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return; + window.addEventListener('offline', () => { + if (!offlineMode.active) { + setOfflineMode(true, false); // auto (session) + scheduleRecovery(4000); // poll for the network to return + } + }); + // The online event, when it fires, just kicks an immediate reachability check; the poll above is + // the reliable fallback for platforms where it does not fire. + window.addEventListener('online', () => { + if (offlineMode.active && offlineMode.auto) scheduleRecovery(0); + }); +} + /** * applyLinkResult applies a completed account link or merge: it adopts a * switched session (a guest initiator whose durable counterpart won, so the active @@ -826,6 +875,8 @@ export async function bootstrap(): Promise { // worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only // and skipped in the mock build; see lib/pwa.svelte. registerServiceWorker(); + // React to mid-session network changes (e.g. flight mode) for the rest of the session. + initNetworkReactivity(); // Deliberate offline mode carried over from a prior session: with no network the session adoption // + profile fetch below would hang the splash, so skip them and launch straight from the cached @@ -868,6 +919,7 @@ export async function bootstrap(): Promise { if (goOffline) { // A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only. setOfflineMode(true, !interfaceOffline); + if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return app.session = saved; app.profile = cachedProfile; if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/'); diff --git a/ui/src/lib/connection.svelte.ts b/ui/src/lib/connection.svelte.ts index 84c43b1..03b30e5 100644 --- a/ui/src/lib/connection.svelte.ts +++ b/ui/src/lib/connection.svelte.ts @@ -9,6 +9,7 @@ // other traffic is in flight. import { backoffMs } from './retry'; +import { offlineMode } from './offline.svelte'; let online = $state(true); let watchTimer: ReturnType | null = null; @@ -39,10 +40,8 @@ export async function checkReachable(timeoutMs: number): Promise { probe(), new Promise((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)), ]); - reportOnline(); return true; } catch { - reportOffline(); return false; } } @@ -71,7 +70,9 @@ function scheduleProbe(attempt: number): void { watchTimer = setTimeout( () => { watchTimer = null; - if (online || !probe) return; + // Never probe the network in offline mode (the kill switch); the online/offline events drive + // recovery there instead. + if (online || !probe || offlineMode.active) return; probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6))); }, backoffMs(attempt), diff --git a/ui/src/lib/offline.svelte.ts b/ui/src/lib/offline.svelte.ts index bcd3434..544e4a7 100644 --- a/ui/src/lib/offline.svelte.ts +++ b/ui/src/lib/offline.svelte.ts @@ -11,13 +11,21 @@ import type { Profile } from './model'; // Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription). let active = $state(loadOfflinePref()); +// True while the current offline state was entered automatically (no network detected), not chosen +// by the player. An auto offline self-heals to online when the network returns; a deliberate one is +// left as the player's choice. +let auto = $state(false); -/** offlineMode exposes the reactive deliberate-offline flag; read it in markup / $derived. */ +/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */ export const offlineMode = { - /** active is true while the app is in deliberate offline mode. */ + /** active is true while the app is in offline mode (deliberate or auto-detected). */ get active(): boolean { return active; }, + /** auto is true while offline was entered automatically (no network), not by the player. */ + get auto(): boolean { + return auto; + }, }; /** @@ -28,6 +36,7 @@ export const offlineMode = { */ export function setOfflineMode(on: boolean, persist = true): void { active = on; + auto = on && !persist; // a non-persisted offline is auto-detected; a persisted one is deliberate if (persist) saveOfflinePref(on); } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index f868d7d..0273a3b 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -40,7 +40,9 @@ 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(); + // Exempt from the offline kill switch: the probe IS the reachability check that decides whether to + // return online, and it fires only deliberately — the connection watcher is guarded off in offline + // mode, and checkReachable runs it on an online event — so it must reach the gateway while offline. await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() }); }; registerProbe(reachabilityProbe);