diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 44cce7e..f333135 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -982,7 +982,11 @@ export async function bootstrap(): Promise { // to online when the network returns. Web-only reaches here (the Mini-App branches returned above), // so isStandalone gates it. const cachedProfile = await loadProfile(); - const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email; + // A native app is offline-first, so a native launch — even a guest with no email — must fall back + // to the cached session's offline lobby rather than hang ~25 s on adoptSession's retrying profile + // fetch when the device is offline (e.g. airplane mode). Web keeps the installed-PWA + email gate. + const nativeChannel = clientChannel() === 'android' || clientChannel() === 'ios'; + const canOffline = !vkcb && (nativeChannel || (!!cachedProfile && isStandalone() && !!cachedProfile.email)); if (canOffline) { const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false; gateway.setToken(saved.token); diff --git a/ui/src/lib/netstate.svelte.ts b/ui/src/lib/netstate.svelte.ts index 939a4d8..65d0c80 100644 --- a/ui/src/lib/netstate.svelte.ts +++ b/ui/src/lib/netstate.svelte.ts @@ -175,7 +175,10 @@ function applyEffect(effect: Effect): void { * poll immediately (the native reconcile) rather than after the first interval. */ export function bootOffline(kickNow = false): void { - snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 }; + // provisional: this offline is an assumption, not an observation — the first probe result confirms + // it (silent heal if reachable, so a first launch that had connectivity does not flash a spurious + // "back online" toast) or turns it into a real offline (see the reducer). + snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true }; arm(kickNow ? 0 : OFFLINE_POLL_MS); } diff --git a/ui/src/lib/netstate.test.ts b/ui/src/lib/netstate.test.ts index 6aa4e3c..fdfd709 100644 --- a/ui/src/lib/netstate.test.ts +++ b/ui/src/lib/netstate.test.ts @@ -248,3 +248,31 @@ describe('reduce — purity', () => { expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 }); }); }); + +// A boot-assumed (provisional) offline — bootOffline sets this — heals SILENTLY on its first +// successful probe (a first launch that actually had connectivity must not flash "back online"), +// while a failed probe confirms it as a real offline whose later recovery does toast. +describe('provisional (boot-assumed) offline', () => { + const bootOffline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true }; + + it('heals to online with NO toast when the first probe succeeds', () => { + const r = reduce(bootOffline, e('probeOk'), cfg); + expect(r.next.state).toBe('online'); + expect(r.effects).toEqual([]); // no "back online" toast — it was never really offline + }); + + it('clears provisional on a failed probe, then toasts on the eventual recovery', () => { + const failed = reduce(bootOffline, e('probeFailed'), cfg); + expect(failed.next.state).toBe('offlineNoNetwork'); + expect(failed.next.provisional).toBe(false); + expect(failed.effects).toEqual([]); + const healed = reduce(failed.next, e('probeOk'), cfg); + expect(healed.next.state).toBe('online'); + expect(healed.effects).toEqual([{ kind: 'toast', toast: 'online' }]); // now a real recovery + }); + + it('a non-provisional (observed) offline still toasts on recovery', () => { + const r = reduce(offline, e('probeOk'), cfg); + expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]); + }); +}); diff --git a/ui/src/lib/netstate.ts b/ui/src/lib/netstate.ts index 894e963..84cd771 100644 --- a/ui/src/lib/netstate.ts +++ b/ui/src/lib/netstate.ts @@ -74,6 +74,14 @@ export interface NetSnapshot { * it (0 while not `connecting`). */ connectingSince: number; + /** + * provisional marks an `offlineNoNetwork` that was ASSUMED at boot (bootOffline), not observed from + * a real online→offline transition. The first probe confirms it: a success means we were reachable + * all along, so the machine heals to online SILENTLY (no "back online" toast — there was nothing to + * come back from); a failure clears the flag, turning it into a confirmed offline whose later + * recovery does toast. Absent/false in every non-boot snapshot. + */ + provisional?: boolean; } /** NetInput is an event stamped with the wall-clock time it occurred at; only the debounce branch @@ -198,13 +206,20 @@ export function reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig): NetResu switch (ev.type) { case 'callOk': case 'probeOk': - // The probe (or a reconcile call) won — self-heal to online with a "back online" toast. - return { next: onlineSnapshot(), effects: [{ kind: 'toast', toast: 'online' }] }; + // The probe (or a reconcile call) won — heal to online. A boot-assumed (provisional) offline + // that is reachable on its very first probe was never really offline, so heal SILENTLY; a + // confirmed offline gets the "back online" toast. + return { next: onlineSnapshot(), effects: prev.provisional ? [] : [{ kind: 'toast', toast: 'online' }] }; + case 'callFailed': + case 'probeFailed': + // A failed probe confirms a boot-assumed offline as real (its eventual recovery will then + // toast); a non-provisional offline is unchanged. + return prev.provisional ? { next: { ...prev, provisional: false }, effects: [] } : { next: prev, effects: [] }; case 'osOnline': // Trigger a probe; stay offline until it actually wins (a captive portal can report online). return { next: prev, effects: [{ kind: 'startProbe' }] }; default: - // callFailed / probeFailed / osOffline (still offline), versionRecommended (no nudge): no-op. + // osOffline (still offline), versionRecommended (no nudge): no-op. return { next: prev, effects: [] }; } }