fix(offline): return online after flight-mode off; gate online-game actions offline
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s

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).
This commit is contained in:
Ilia Denisov
2026-07-06 18:35:52 +02:00
parent e0d28733ff
commit fc8143758a
2 changed files with 31 additions and 13 deletions
+12 -6
View File
@@ -13,6 +13,7 @@
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { GatewayError } from '../lib/client'; import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; 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/ // 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. // draft) through it, so the same UI drives a local vs_ai game and an online one alike.
const source = $derived(gameSource(id)); 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). // Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
let localUnsub: (() => void) | null = null; let localUnsub: (() => void) | null = null;
@@ -1481,19 +1487,19 @@
/> />
</div> </div>
{#if !gameOver && placement.pending.length > 0 && !recallOverRack} {#if !gameOver && placement.pending.length > 0 && !recallOverRack}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button> <button class="make" onclick={commit} disabled={busy || !isMyTurn || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
{#snippet controlButtons()} {#snippet controlButtons()}
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}> <button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={openExchange}>
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span> <span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
</button> </button>
<TapConfirm <TapConfirm
triggerClass="tab" triggerClass="tab"
label={t('game.hint')} label={t('game.hint')}
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0} disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
onconfirm={doHint} onconfirm={doHint}
> >
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span> <span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
@@ -1551,7 +1557,7 @@
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}> <Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row"> <div class="confirm-row">
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button> <button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button> <button class="danger" onclick={doResign} disabled={!netReady}>{t('game.dropGame')}</button>
</div> </div>
</Modal> </Modal>
{/if} {/if}
@@ -1563,7 +1569,7 @@
<button <button
class="confirm" class="confirm"
onclick={() => { exportOpen = false; void exportArtifact('png'); }} onclick={() => { exportOpen = false; void exportArtifact('png'); }}
disabled={!connection.online} disabled={!netReady}
> >
{t('game.exportImageOpt')} {t('game.exportImageOpt')}
</button> </button>
@@ -1571,7 +1577,7 @@
<button <button
class={exportImageAvailable ? 'export-alt' : 'confirm'} class={exportImageAvailable ? 'export-alt' : 'confirm'}
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }} onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
disabled={!connection.online} disabled={!netReady}
> >
{t('game.exportGcgOpt')} {t('game.exportGcgOpt')}
</button> </button>
+19 -7
View File
@@ -304,7 +304,9 @@ export function handleError(err: unknown): void {
void enterBlocked(); void enterBlocked();
return; 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'); haptic('error');
showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
} }
@@ -585,18 +587,28 @@ function promptOfflineChoice(): Promise<boolean> {
* cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no * 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). * 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<void> {
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 { export function initNetworkReactivity(): void {
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return; if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
if (!offlineMode.active) setOfflineMode(true, false); // auto (session) — self-heals below if (!offlineMode.active) setOfflineMode(true, false); // auto (session) — self-heals below
}); });
window.addEventListener('online', () => { window.addEventListener('online', () => {
if (offlineMode.active && offlineMode.auto) { if (offlineMode.active && offlineMode.auto) void tryReturnOnline();
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);
});
}
}); });
} }