feat(offline): mid-session flight-mode reactivity [PR2] #203

Merged
developer merged 5 commits from feature/offline-flightmode into development 2026-07-06 17:31:17 +00:00
Showing only changes of commit fffc6030ce - Show all commits
+26 -11
View File
@@ -587,28 +587,42 @@ 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 // While in auto-offline, poll for the network to return so the app comes back online — robust to the
// not mean the gateway is reachable yet (DNS/routing settle for a moment after flight mode), so it // unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it
// re-checks a few times with backoff before returning online, stopping if the player takes over the // reads navigator.onLine (no radio) and only hits the network (a reachability check) when the
// state meanwhile. This bounded retry is what actually gets the app back online after flight mode off. // interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in
async function tryReturnOnline(): Promise<void> { // auto-offline (a deliberate offline is the player's choice) and stops on returning online.
for (let attempt = 0; attempt < 4; attempt++) { 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; if (!offlineMode.active || !offlineMode.auto) return;
if (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS)) { const interfaceUp = typeof navigator === 'undefined' || navigator.onLine;
if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) {
if (offlineMode.active && offlineMode.auto) setOfflineMode(false); if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
return; return;
} }
await new Promise((r) => setTimeout(r, 600 * (attempt + 1))); scheduleRecovery(4000);
} }, delayMs);
} }
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)
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', () => { window.addEventListener('online', () => {
if (offlineMode.active && offlineMode.auto) void tryReturnOnline(); if (offlineMode.active && offlineMode.auto) scheduleRecovery(0);
}); });
} }
@@ -905,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('/');