From fc8143758ae1a3f659d3ba68e092b54ce1500a40 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 18:35:52 +0200 Subject: [PATCH] fix(offline): return online after flight-mode off; gate online-game actions offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two mid-session issues found on the contour: - Auto-offline did not return to online after the network came back: a single reachability check right after the 'online' event failed (the interface is up before the gateway is actually reachable again). Retry a few times with backoff (tryReturnOnline) — that is what actually gets the app back online. - An online game viewed while offline (flight mode on) still enabled its network actions, so they hit the kill switch and raised 'something went wrong' toasts. Gate them: netReady = isLocalGame || (connection.online && !offlineMode.active) — a local game stays fully usable; an online game's make/exchange/hint/resign disable while offline and re-enable when back. Also suppress the 'offline' code in handleError (a blocked call in offline mode is expected, not a toast). --- ui/src/game/Game.svelte | 18 ++++++++++++------ ui/src/lib/app.svelte.ts | 26 +++++++++++++++++++------- 2 files changed, 31 insertions(+), 13 deletions(-) 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 ab1b8c8..df4731a 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'); } @@ -585,18 +587,28 @@ function promptOfflineChoice(): Promise { * 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). */ +// tryReturnOnline is fired from the online event while in auto-offline: the interface being back does +// not mean the gateway is reachable yet (DNS/routing settle for a moment after flight mode), so it +// re-checks a few times with backoff before returning online, stopping if the player takes over the +// state meanwhile. This bounded retry is what actually gets the app back online after flight mode off. +async function tryReturnOnline(): Promise { + for (let attempt = 0; attempt < 4; attempt++) { + if (!offlineMode.active || !offlineMode.auto) return; + if (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS)) { + if (offlineMode.active && offlineMode.auto) setOfflineMode(false); + return; + } + await new Promise((r) => setTimeout(r, 600 * (attempt + 1))); + } +} + 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) — self-heals below }); window.addEventListener('online', () => { - if (offlineMode.active && offlineMode.auto) { - void checkReachable(BOOT_REACHABILITY_TIMEOUT_MS).then((reachable) => { - // Re-read the flags — the player may have chosen offline meanwhile — before returning online. - if (reachable && offlineMode.active && offlineMode.auto) setOfflineMode(false); - }); - } + if (offlineMode.active && offlineMode.auto) void tryReturnOnline(); }); }