diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 77ef40c..ab1b8c8 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -577,6 +577,29 @@ 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). + */ +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); + }); + } + }); +} + /** * 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 +849,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 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..dd2d5d5 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; + }, }; /** 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);