feat(offline): mid-session flight-mode reactivity (auto-offline self-heals)
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
React to the network changing while the app is open (e.g. the player toggling flight mode), via passive online/offline events - no polling, no battery cost: - interface lost -> enter offline mode for the session (auto); - interface back -> if the offline was auto, verify the gateway is really reachable (an interface being up does not guarantee it) and return online; a deliberate offline (the toggle or the cold-start dialog) is left as-is. - offline.svelte: track `auto` (auto-detected vs the player's deliberate choice). - connection.svelte: checkReachable is now a pure one-shot (the caller decides); the reachability watcher never probes in offline mode (events drive recovery). - transport.ts: the reachability probe is exempt from the kill switch - it IS the mechanism that decides whether to return online, fired only deliberately. - app.svelte.ts: initNetworkReactivity wires the events (web-only, skipped in the mock); called from bootstrap. Online unaffected (skipped in the mock e2e): e2e 196. Mid-session reactivity is contour-verified.
This commit is contained in:
@@ -577,6 +577,29 @@ 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).
|
||||
*/
|
||||
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<void> {
|
||||
// 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
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
@@ -39,10 +40,8 @@ export async function checkReachable(timeoutMs: number): Promise<boolean> {
|
||||
probe(),
|
||||
new Promise<never>((_, 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),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user