From e0d28733ff53b3f4d2c45d24e577e3ed534690db Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 18:15:12 +0200 Subject: [PATCH 1/5] feat(offline): mid-session flight-mode reactivity (auto-offline self-heals) 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. --- ui/src/lib/app.svelte.ts | 25 +++++++++++++++++++++++++ ui/src/lib/connection.svelte.ts | 7 ++++--- ui/src/lib/offline.svelte.ts | 12 ++++++++++-- ui/src/lib/transport.ts | 4 +++- 4 files changed, 42 insertions(+), 6 deletions(-) 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); -- 2.52.0 From fc8143758ae1a3f659d3ba68e092b54ce1500a40 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 18:35:52 +0200 Subject: [PATCH 2/5] fix(offline): return online after flight-mode off; gate online-game actions offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui/src/game/Game.svelte | 18 ++++++++++++------ ui/src/lib/app.svelte.ts | 26 +++++++++++++++++++------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 735c3ff..e5cc082 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -13,6 +13,7 @@ import { navigate } from '../lib/router.svelte'; import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; + import { offlineMode } from '../lib/offline.svelte'; import { GatewayError } from '../lib/client'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; 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/ // draft) through it, so the same UI drives a local vs_ai game and an online one alike. 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). let localUnsub: (() => void) | null = null; @@ -1481,19 +1487,19 @@ /> {#if !gameOver && placement.pending.length > 0 && !recallOverRack} - + {/if} {/snippet} {#snippet controlButtons()} - 🛟{#if hintCount > 0}{hintCount}{/if} @@ -1551,7 +1557,7 @@ (resignOpen = false)}>
- +
{/if} @@ -1563,7 +1569,7 @@ @@ -1571,7 +1577,7 @@ diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index ab1b8c8..df4731a 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -304,7 +304,9 @@ export function handleError(err: unknown): void { void enterBlocked(); 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'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); } @@ -585,18 +587,28 @@ function promptOfflineChoice(): Promise { * 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). */ +// 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 { + 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 { 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); - }); - } + if (offlineMode.active && offlineMode.auto) void tryReturnOnline(); }); } -- 2.52.0 From fffc6030ceb3186f3646ff2376f22cb7627514c6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 18:50:10 +0200 Subject: [PATCH 3/5] fix(offline): poll navigator.onLine to return online (the online event is unreliable) The auto-offline -> online return still did not fire: the retry hung on the 'online' event, which an installed PWA often does not deliver. Replace it with a lightweight poll while in auto-offline that reads navigator.onLine (a reliable live flag, unlike the event) and only hits the network for a reachability check when the interface is actually up - so flight mode ON costs no radio, and flight mode OFF is detected within ~4s and returns online. The 'online' event, when it does fire, just kicks an immediate check. Runs only in auto-offline (deliberate offline is the player's choice); wired for both the mid-session and cold-start auto-offline paths. --- ui/src/lib/app.svelte.ts | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index df4731a..49ab6aa 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -587,28 +587,42 @@ function promptOfflineChoice(): Promise { * 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). */ -// 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 { - for (let attempt = 0; attempt < 4; attempt++) { +// 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 | 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 (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); return; } - await new Promise((r) => setTimeout(r, 600 * (attempt + 1))); - } + 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) — 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', () => { - if (offlineMode.active && offlineMode.auto) void tryReturnOnline(); + if (offlineMode.active && offlineMode.auto) scheduleRecovery(0); }); } @@ -905,6 +919,7 @@ export async function bootstrap(): Promise { if (goOffline) { // A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only. setOfflineMode(true, !interfaceOffline); + if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return app.session = saved; app.profile = cachedProfile; if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/'); -- 2.52.0 From 09c5a5e72e80d8ef0b8dbf9d55ea0dbc99ca83b0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 19:03:33 +0200 Subject: [PATCH 4/5] chore(offline): TEMPORARY network diagnostic panel (remove before release) A fixed strip in the lobby showing live navigator.onLine / offline.active / offline.auto / connection.online plus a timestamped event log (offline/online events, poll ticks, checkReachable results, mode changes) - to pinpoint where the auto-offline -> online transition breaks on the contour. A 1s heartbeat logs navigator.onLine flips even if the events never fire. Skipped in the mock; to be reverted with the fix. --- ui/src/lib/app.svelte.ts | 16 ++++++++++++--- ui/src/lib/netdiag.svelte.ts | 38 ++++++++++++++++++++++++++++++++++++ ui/src/screens/Lobby.svelte | 10 ++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 ui/src/lib/netdiag.svelte.ts diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 49ab6aa..6d751ea 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -57,6 +57,7 @@ import { import type { CoachSeries } from './coachmark'; import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte'; import { offlineMode, setOfflineMode } from './offline.svelte'; +import { netlog } from './netdiag.svelte'; // TEMP diagnostic import { shouldBootOffline } from './offline'; import { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; @@ -603,9 +604,15 @@ export function scheduleRecovery(delayMs: number): void { 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; + netlog(`poll tick: navOnline=${interfaceUp}`); // TEMP diagnostic + if (interfaceUp) { + const reachable = await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS); + netlog(`checkReachable=${reachable}`); // TEMP diagnostic + if (reachable && offlineMode.active && offlineMode.auto) { + setOfflineMode(false); + netlog('-> RETURNED ONLINE'); // TEMP diagnostic + return; + } } scheduleRecovery(4000); }, delayMs); @@ -614,14 +621,17 @@ export function scheduleRecovery(delayMs: number): void { export function initNetworkReactivity(): void { if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return; window.addEventListener('offline', () => { + netlog(`offline EVENT (active=${offlineMode.active})`); // TEMP diagnostic if (!offlineMode.active) { setOfflineMode(true, false); // auto (session) + netlog('-> auto-offline; poll started'); // TEMP diagnostic 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', () => { + netlog(`online EVENT (active=${offlineMode.active} auto=${offlineMode.auto})`); // TEMP diagnostic if (offlineMode.active && offlineMode.auto) scheduleRecovery(0); }); } diff --git a/ui/src/lib/netdiag.svelte.ts b/ui/src/lib/netdiag.svelte.ts new file mode 100644 index 0000000..d1a487c --- /dev/null +++ b/ui/src/lib/netdiag.svelte.ts @@ -0,0 +1,38 @@ +// TEMPORARY network diagnostic — REMOVE before release. A live view of the online/offline signals so +// we can pinpoint where the auto-offline -> online transition breaks. Rendered by a panel in +// Lobby.svelte. Skipped in the mock build. + +let entries = $state([]); +let navOnline = $state(typeof navigator !== 'undefined' ? navigator.onLine : true); + +export const netdiag = { + /** entries is the newest-first ring buffer of timestamped diagnostic lines. */ + get entries(): string[] { + return entries; + }, + /** navOnline is the live navigator.onLine value (the property itself is not reactive). */ + get navOnline(): boolean { + return navOnline; + }, +}; + +/** netlog appends a timestamped diagnostic line (kept to the last 24). */ +export function netlog(msg: string): void { + const t = new Date().toLocaleTimeString(); + entries = [`${t} ${msg}`, ...entries].slice(0, 24); + if (typeof navigator !== 'undefined') navOnline = navigator.onLine; +} + +// A 1 s heartbeat keeps navigator.onLine live in the panel and logs when it flips — so a flight-mode +// toggle is visible even if the online/offline events never fire (the exact thing we are chasing). +if (typeof window !== 'undefined' && import.meta.env.MODE !== 'mock') { + let last = typeof navigator !== 'undefined' ? navigator.onLine : true; + setInterval(() => { + const now = typeof navigator !== 'undefined' ? navigator.onLine : true; + navOnline = now; + if (now !== last) { + last = now; + netlog(`navigator.onLine -> ${now}`); + } + }, 1000); +} diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 1cd9405..60ac74b 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -6,6 +6,7 @@ import Modal from '../components/Modal.svelte'; import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; + import { netdiag } from '../lib/netdiag.svelte'; // TEMP diagnostic import { gateway } from '../lib/gateway'; import { navigate } from '../lib/router.svelte'; import { t } from '../lib/i18n/index.svelte'; @@ -258,6 +259,15 @@ } + +
+
+ nav.onLine={String(netdiag.navOnline)} · offline={String(offlineMode.active)} · auto={String(offlineMode.auto)} · conn.online={String(connection.online)} +
+ {#each netdiag.entries as e, i (i)}
{e}
{/each} +
+
{#if invitations.length} -- 2.52.0 From 3ad66f49c735bab83d7dc1ea7937dac9edf29a2c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 19:20:59 +0200 Subject: [PATCH 5/5] fix(offline): set the auto flag in setOfflineMode; remove temp diagnostic The auto-offline -> online return was dead because setOfflineMode never set the 'auto' flag: I added the state + getter but forgot the assignment, so it stayed false. So scheduleRecovery bailed immediately (no poll ran) and the online event's 'if (active && auto)' was false. The contour diagnostic confirmed offline.auto stayed false after an auto-offline. Add 'auto = on && !persist'. Also remove the temporary network diagnostic panel (netdiag + the lobby strip) -- it did its job of pinpointing this. --- ui/src/lib/app.svelte.ts | 16 +++------------ ui/src/lib/netdiag.svelte.ts | 38 ------------------------------------ ui/src/lib/offline.svelte.ts | 1 + ui/src/screens/Lobby.svelte | 10 ---------- 4 files changed, 4 insertions(+), 61 deletions(-) delete mode 100644 ui/src/lib/netdiag.svelte.ts diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 6d751ea..49ab6aa 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -57,7 +57,6 @@ import { import type { CoachSeries } from './coachmark'; import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte'; import { offlineMode, setOfflineMode } from './offline.svelte'; -import { netlog } from './netdiag.svelte'; // TEMP diagnostic import { shouldBootOffline } from './offline'; import { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; @@ -604,15 +603,9 @@ export function scheduleRecovery(delayMs: number): void { recoveryTimer = null; if (!offlineMode.active || !offlineMode.auto) return; const interfaceUp = typeof navigator === 'undefined' || navigator.onLine; - netlog(`poll tick: navOnline=${interfaceUp}`); // TEMP diagnostic - if (interfaceUp) { - const reachable = await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS); - netlog(`checkReachable=${reachable}`); // TEMP diagnostic - if (reachable && offlineMode.active && offlineMode.auto) { - setOfflineMode(false); - netlog('-> RETURNED ONLINE'); // TEMP diagnostic - return; - } + if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) { + if (offlineMode.active && offlineMode.auto) setOfflineMode(false); + return; } scheduleRecovery(4000); }, delayMs); @@ -621,17 +614,14 @@ export function scheduleRecovery(delayMs: number): void { export function initNetworkReactivity(): void { if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return; window.addEventListener('offline', () => { - netlog(`offline EVENT (active=${offlineMode.active})`); // TEMP diagnostic if (!offlineMode.active) { setOfflineMode(true, false); // auto (session) - netlog('-> auto-offline; poll started'); // TEMP diagnostic 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', () => { - netlog(`online EVENT (active=${offlineMode.active} auto=${offlineMode.auto})`); // TEMP diagnostic if (offlineMode.active && offlineMode.auto) scheduleRecovery(0); }); } diff --git a/ui/src/lib/netdiag.svelte.ts b/ui/src/lib/netdiag.svelte.ts deleted file mode 100644 index d1a487c..0000000 --- a/ui/src/lib/netdiag.svelte.ts +++ /dev/null @@ -1,38 +0,0 @@ -// TEMPORARY network diagnostic — REMOVE before release. A live view of the online/offline signals so -// we can pinpoint where the auto-offline -> online transition breaks. Rendered by a panel in -// Lobby.svelte. Skipped in the mock build. - -let entries = $state([]); -let navOnline = $state(typeof navigator !== 'undefined' ? navigator.onLine : true); - -export const netdiag = { - /** entries is the newest-first ring buffer of timestamped diagnostic lines. */ - get entries(): string[] { - return entries; - }, - /** navOnline is the live navigator.onLine value (the property itself is not reactive). */ - get navOnline(): boolean { - return navOnline; - }, -}; - -/** netlog appends a timestamped diagnostic line (kept to the last 24). */ -export function netlog(msg: string): void { - const t = new Date().toLocaleTimeString(); - entries = [`${t} ${msg}`, ...entries].slice(0, 24); - if (typeof navigator !== 'undefined') navOnline = navigator.onLine; -} - -// A 1 s heartbeat keeps navigator.onLine live in the panel and logs when it flips — so a flight-mode -// toggle is visible even if the online/offline events never fire (the exact thing we are chasing). -if (typeof window !== 'undefined' && import.meta.env.MODE !== 'mock') { - let last = typeof navigator !== 'undefined' ? navigator.onLine : true; - setInterval(() => { - const now = typeof navigator !== 'undefined' ? navigator.onLine : true; - navOnline = now; - if (now !== last) { - last = now; - netlog(`navigator.onLine -> ${now}`); - } - }, 1000); -} diff --git a/ui/src/lib/offline.svelte.ts b/ui/src/lib/offline.svelte.ts index dd2d5d5..544e4a7 100644 --- a/ui/src/lib/offline.svelte.ts +++ b/ui/src/lib/offline.svelte.ts @@ -36,6 +36,7 @@ export const offlineMode = { */ export function setOfflineMode(on: boolean, persist = true): void { active = on; + auto = on && !persist; // a non-persisted offline is auto-detected; a persisted one is deliberate if (persist) saveOfflinePref(on); } diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 60ac74b..1cd9405 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -6,7 +6,6 @@ import Modal from '../components/Modal.svelte'; import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; - import { netdiag } from '../lib/netdiag.svelte'; // TEMP diagnostic import { gateway } from '../lib/gateway'; import { navigate } from '../lib/router.svelte'; import { t } from '../lib/i18n/index.svelte'; @@ -259,15 +258,6 @@ } - -
-
- nav.onLine={String(netdiag.navOnline)} · offline={String(offlineMode.active)} · auto={String(offlineMode.auto)} · conn.online={String(connection.online)} -
- {#each netdiag.entries as e, i (i)}
{e}
{/each} -
-
{#if invitations.length} -- 2.52.0