Merge pull request 'feat(offline): mid-session flight-mode reactivity [PR2]' (#203) from feature/offline-flightmode into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m52s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m52s
This commit was merged in pull request #203.
This commit is contained in:
+12
-6
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
@@ -577,6 +579,53 @@ function promptOfflineChoice(): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<typeof setTimeout> | 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
|
* applyLinkResult applies a completed account link or merge: it adopts a
|
||||||
* switched session (a guest initiator whose durable counterpart won, so the active
|
* switched session (a guest initiator whose durable counterpart won, so the active
|
||||||
@@ -826,6 +875,8 @@ export async function bootstrap(): Promise<void> {
|
|||||||
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
|
// 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.
|
// and skipped in the mock build; see lib/pwa.svelte.
|
||||||
registerServiceWorker();
|
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
|
// 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
|
// + 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<void> {
|
|||||||
if (goOffline) {
|
if (goOffline) {
|
||||||
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
|
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
|
||||||
setOfflineMode(true, !interfaceOffline);
|
setOfflineMode(true, !interfaceOffline);
|
||||||
|
if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return
|
||||||
app.session = saved;
|
app.session = saved;
|
||||||
app.profile = cachedProfile;
|
app.profile = cachedProfile;
|
||||||
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// other traffic is in flight.
|
// other traffic is in flight.
|
||||||
|
|
||||||
import { backoffMs } from './retry';
|
import { backoffMs } from './retry';
|
||||||
|
import { offlineMode } from './offline.svelte';
|
||||||
|
|
||||||
let online = $state(true);
|
let online = $state(true);
|
||||||
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -39,10 +40,8 @@ export async function checkReachable(timeoutMs: number): Promise<boolean> {
|
|||||||
probe(),
|
probe(),
|
||||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
|
||||||
]);
|
]);
|
||||||
reportOnline();
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
reportOffline();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +70,9 @@ function scheduleProbe(attempt: number): void {
|
|||||||
watchTimer = setTimeout(
|
watchTimer = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
watchTimer = null;
|
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)));
|
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
|
||||||
},
|
},
|
||||||
backoffMs(attempt),
|
backoffMs(attempt),
|
||||||
|
|||||||
@@ -11,13 +11,21 @@ import type { Profile } from './model';
|
|||||||
|
|
||||||
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
|
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
|
||||||
let active = $state(loadOfflinePref());
|
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 = {
|
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 {
|
get active(): boolean {
|
||||||
return active;
|
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 {
|
export function setOfflineMode(on: boolean, persist = true): void {
|
||||||
active = on;
|
active = on;
|
||||||
|
auto = on && !persist; // a non-persisted offline is auto-detected; a persisted one is deliberate
|
||||||
if (persist) saveOfflinePref(on);
|
if (persist) saveOfflinePref(on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
||||||
const reachabilityProbe = async (): Promise<void> => {
|
const reachabilityProbe = async (): Promise<void> => {
|
||||||
if (!token) throw new Error('no session');
|
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() });
|
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
||||||
};
|
};
|
||||||
registerProbe(reachabilityProbe);
|
registerProbe(reachabilityProbe);
|
||||||
|
|||||||
Reference in New Issue
Block a user