From 19e7ea5da052cc8f5774869d3457d7f49fc4a3f0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 15:20:15 +0200 Subject: [PATCH] fix(offline): persist a plain profile snapshot; keep the sticky offline flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offline cold-boot never engaged: it persisted app.profile — a Svelte $state proxy — which is not structured-cloneable, so the IndexedDB write threw and fell back to a localStorage entry that loadProfile (IDB-only on a successful-empty read) never reads. loadProfile returned null, the boot short-circuit missed, and it even cleared the sticky offline flag — so offline mode stopped persisting across relaunches (owner-observed on the contour). - Persist $state.snapshot(app.profile) (a plain object) at both persist sites, so the IndexedDB write succeeds and loadProfile round-trips. - Drop the setOfflineMode(false) fallback: keep the deliberate offline flag on a cache miss (the mode is the player's choice; an online boot re-persists the profile so the next launch goes offline). A truly offline launch with no cached profile is unreachable (enabling offline needs a prior online session). --- ui/src/lib/app.svelte.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index a4041fe..58ed777 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -56,7 +56,7 @@ import { } from './session'; import type { CoachSeries } from './coachmark'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; -import { offlineMode, setOfflineMode } from './offline.svelte'; +import { offlineMode } from './offline.svelte'; import { shouldBootOffline } from './offline'; import { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; @@ -465,7 +465,7 @@ export async function refreshProfile(): Promise { if (!app.session) return; try { app.profile = await gateway.profileGet(); - void saveProfile(app.profile); // keep the offline-boot cache fresh + void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot) } catch { // Best-effort; the banner just stays as it was until the next fetch. } @@ -524,8 +524,10 @@ async function adoptSession(s: Session): Promise { try { app.profile = await gateway.profileGet(); // Persist the profile so a later offline cold start can launch from it (its variant - // preferences, dictionary versions and display name) without a network fetch. - void saveProfile(app.profile); + // preferences, dictionary versions and display name) without a network fetch. Snapshot to a + // plain object first — the reactive $state proxy is not structured-cloneable, so it would fail + // the IndexedDB write and fall back to a localStorage entry that loadProfile never reads. + void saveProfile($state.snapshot(app.profile)); // The live interface language follows the device — the explicit local choice (saved in // prefs) or the system guess made at bootstrap — and is no longer overridden from the // account here: the Telegram bot a user signs in through must not dictate the UI, so a @@ -801,9 +803,10 @@ export async function bootstrap(): Promise { // 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 - // session + profile into the offline lobby. Without both (e.g. cleared storage, or an install that - // was never online), drop the sticky flag and fall through to the normal online flow — the first - // launch must be online. + // session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall + // through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and + // an online boot re-persists the profile so the next launch goes offline. (A truly offline launch + // with no cached profile is unreachable: enabling offline requires a prior online session.) if (offlineMode.active) { const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]); if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) { @@ -814,7 +817,6 @@ export async function bootstrap(): Promise { app.ready = true; return; } - setOfflineMode(false); } const saved = await loadSession();