Merge pull request 'fix(offline): cold-boot offline from the persisted session + profile' (#199) from feature/offline-boot into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s

This commit was merged in pull request #199.
This commit is contained in:
2026-07-06 14:25:11 +00:00
12 changed files with 123 additions and 10 deletions
+5 -1
View File
@@ -1259,7 +1259,11 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried — on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
the ad-banner slot. The gateway registers the `.webmanifest` MIME type the ad-banner slot. A **cold launch already in offline mode** boots from the persisted session and
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
and boots online instead. The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are `immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An `no-cache` so a new deploy is picked up — both containers apply the same caching. An
+30
View File
@@ -43,6 +43,9 @@ import { parseStartParam } from './deeplink';
import { import {
clearOnboarding, clearOnboarding,
clearSession, clearSession,
clearProfile,
loadProfile,
saveProfile,
loadOnboarding, loadOnboarding,
loadPrefs, loadPrefs,
loadSession, loadSession,
@@ -53,6 +56,8 @@ import {
} from './session'; } from './session';
import type { CoachSeries } from './coachmark'; import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { offlineMode } from './offline.svelte';
import { shouldBootOffline } from './offline';
import { isConnectionCode } from './retry'; import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta'; import { advanceCached } from './gamedelta';
@@ -460,6 +465,7 @@ export async function refreshProfile(): Promise<void> {
if (!app.session) return; if (!app.session) return;
try { try {
app.profile = await gateway.profileGet(); app.profile = await gateway.profileGet();
void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot)
} catch { } catch {
// Best-effort; the banner just stays as it was until the next fetch. // Best-effort; the banner just stays as it was until the next fetch.
} }
@@ -517,6 +523,11 @@ async function adoptSession(s: Session): Promise<void> {
await saveSession(s); await saveSession(s);
try { try {
app.profile = await gateway.profileGet(); 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. 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 // 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 // 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 // account here: the Telegram bot a user signs in through must not dictate the UI, so a
@@ -790,6 +801,24 @@ export async function bootstrap(): Promise<void> {
// and skipped in the mock build; see lib/pwa.svelte. // and skipped in the mock build; see lib/pwa.svelte.
registerServiceWorker(); registerServiceWorker();
// 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 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 })) {
gateway.setToken(offlineSession!.token);
app.session = offlineSession!;
app.profile = offlineProfile!;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
}
const saved = await loadSession(); const saved = await loadSession();
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back // A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
// from VK; capture and clear it here (it needs the restored session to link against). // from VK; capture and clear it here (it needs the restored session to link against).
@@ -1023,6 +1052,7 @@ export async function logout(): Promise<void> {
app.splashDone = false; app.splashDone = false;
gateway.setToken(null); gateway.setToken(null);
await clearSession(); await clearSession();
await clearProfile();
app.session = null; app.session = null;
app.profile = null; app.profile = null;
navigate('/login'); navigate('/login');
+3
View File
@@ -17,6 +17,9 @@ import type { Variant } from '../model';
export interface Seat { export interface Seat {
kind: 'human' | 'robot'; kind: 'human' | 'robot';
name: string; name: string;
/** The player's real account id for a human seat, so the client identifies "you" and the correct
* turn exactly as in an online game; absent for the robot (a synthetic id is derived). */
accountId?: string;
} }
/** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */ /** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */
+14
View File
@@ -51,6 +51,20 @@ describe('LocalSource.list', () => {
expect(list.every((g) => g.vsAi && g.status === 'active')).toBe(true); expect(list.every((g) => g.vsAi && g.status === 'active')).toBe(true);
}); });
it('carries the human seat account id into the GameView so the client identifies "you"', async () => {
const src = new LocalSource();
await src.create({
...opts('local:me', 3n),
seats: [
{ kind: 'human', name: 'You', accountId: 'acct-42' },
{ kind: 'robot', name: 'Robot' },
],
});
const [g] = await src.list();
expect(g.seats[0].accountId).toBe('acct-42'); // the human's real id, matched against the session
expect(g.seats[1].accountId).not.toBe('acct-42'); // the robot keeps a synthetic id
});
it('reconstructs a game persisted by a previous session (not in the source cache)', async () => { it('reconstructs a game persisted by a previous session (not in the source cache)', async () => {
// Seed the store via one source, then list from a fresh source whose in-memory cache is empty. // Seed the store via one source, then list from a fresh source whose in-memory cache is empty.
await new LocalSource().create(opts('local:old', 7n)); await new LocalSource().create(opts('local:old', 7n));
+2 -2
View File
@@ -120,7 +120,7 @@ export class LocalSource implements GameLoopSource {
const now = nowUnix(); const now = nowUnix();
const record = serializeGame(game, { const record = serializeGame(game, {
id: opts.id, id: opts.id,
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name })), seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })),
createdAtUnix: now, createdAtUnix: now,
updatedAtUnix: now, updatedAtUnix: now,
robotLastMoveAtUnix: now, robotLastMoveAtUnix: now,
@@ -341,7 +341,7 @@ export class LocalSource implements GameLoopSource {
const { game, record } = entry; const { game, record } = entry;
const seats: Seat[] = record.seats.map((s, i) => ({ const seats: Seat[] = record.seats.map((s, i) => ({
seat: i, seat: i,
accountId: `${LOCAL_ID_PREFIX}${s.kind}:${i}`, accountId: s.accountId ?? `${LOCAL_ID_PREFIX}${s.kind}:${i}`,
displayName: s.name, displayName: s.name,
score: game.scoreOf(i), score: game.scoreOf(i),
hintsUsed: 0, hintsUsed: 0,
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible } from './offline'; import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline } from './offline';
import type { Variant } from './model'; import type { Variant } from './model';
// A minimal in-memory localStorage for the persistence tests (node has none). // A minimal in-memory localStorage for the persistence tests (node has none).
@@ -45,4 +45,11 @@ describe('offline mode helpers', () => {
expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false); expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false);
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false); expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
}); });
it('shouldBootOffline requires the offline flag plus a cached session and profile', () => {
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true);
expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false);
expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false);
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
});
}); });
+10
View File
@@ -58,3 +58,13 @@ export function offlinePreloadEligible(opts: {
}): boolean { }): boolean {
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online; return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
} }
/**
* shouldBootOffline decides whether a cold start skips the network and launches straight into
* offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached
* session and profile to start from. Without both (e.g. cleared storage, or a never-online install),
* the app must boot online once first — the caller then drops the sticky flag and continues online.
*/
export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean {
return opts.offlineActive && opts.hasSession && opts.hasProfile;
}
+20 -1
View File
@@ -3,7 +3,7 @@
// re-login), with a localStorage fallback. Losing the store just means re-login — // re-login), with a localStorage fallback. Losing the store just means re-login —
// acceptable, and for a guest it simply mints a fresh guest. // acceptable, and for a guest it simply mints a fresh guest.
import type { Session } from './model'; import type { Session, Profile } from './model';
import type { ThemePref } from './theme'; import type { ThemePref } from './theme';
import type { Locale } from './i18n/catalog'; import type { Locale } from './i18n/catalog';
import type { BoardLabelMode } from './boardlabels'; import type { BoardLabelMode } from './boardlabels';
@@ -119,6 +119,25 @@ export function clearSession(): Promise<void> {
return kvDel(SESSION_KEY); return kvDel(SESSION_KEY);
} }
const PROFILE_KEY = 'profile';
/** loadProfile reads the last persisted profile (or null). It backs the offline cold-boot: with no
* network, bootstrap uses it instead of fetching a fresh one. */
export function loadProfile(): Promise<Profile | null> {
return kvGet<Profile>(PROFILE_KEY);
}
/** saveProfile persists the caller's own profile so a later offline launch can start from it (its
* variant preferences, dictionary versions and display name), best-effort. */
export function saveProfile(p: Profile): Promise<void> {
return kvSet(PROFILE_KEY, p);
}
/** clearProfile drops the persisted profile (on logout, with the session). */
export function clearProfile(): Promise<void> {
return kvDel(PROFILE_KEY);
}
export interface Prefs { export interface Prefs {
theme: ThemePref; theme: ThemePref;
locale: Locale; locale: Locale;
+14
View File
@@ -12,6 +12,7 @@ import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec'; import * as codec from './codec';
import { browserOffset } from './profileValidation'; import { browserOffset } from './profileValidation';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
import { offlineMode } from './offline.svelte';
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
import { maintenanceRetryMs } from './maintenance'; import { maintenanceRetryMs } from './maintenance';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
@@ -19,6 +20,13 @@ import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'
const MAX_RETRIES = 6; const MAX_RETRIES = 6;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
// The offline kill switch: in deliberate offline mode every network operation is refused before it
// leaves the device, so offline truly means offline regardless of any UI affordance that slips
// through. The local (device-only) game path never uses this transport, so it is unaffected.
function assertOnline(): void {
if (offlineMode.active) throw new GatewayError('offline');
}
export function createTransport(baseUrl: string): GatewayClient { export function createTransport(baseUrl: string): GatewayClient {
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : ''); const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true }); const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });
@@ -32,6 +40,7 @@ export function createTransport(baseUrl: string): GatewayClient {
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up). // (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
const reachabilityProbe = async (): Promise<void> => { const reachabilityProbe = async (): Promise<void> => {
if (!token) throw new Error('no session'); if (!token) throw new Error('no session');
assertOnline();
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() }); await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
}; };
registerProbe(reachabilityProbe); registerProbe(reachabilityProbe);
@@ -43,6 +52,7 @@ export function createTransport(baseUrl: string): GatewayClient {
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting // dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
// indicator. A successful round-trip marks the gateway reachable; a domain result_code is final. // indicator. A successful round-trip marks the gateway reachable; a domain result_code is final.
async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> { async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> {
assertOnline();
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
let res; let res;
try { try {
@@ -82,6 +92,7 @@ export function createTransport(baseUrl: string): GatewayClient {
async fetchDict(variant, version, opts) { async fetchDict(variant, version, opts) {
if (!token) throw new Error('fetchDict: no session'); if (!token) throw new Error('fetchDict: no session');
assertOnline();
// Low priority so the browser schedules the (large) dictionary behind the game's own // Low priority so the browser schedules the (large) dictionary behind the game's own
// requests — the move eval, state and live events — on a slow link (EDGE). Ignored // requests — the move eval, state and live events — on a slow link (EDGE). Ignored
// where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The // where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The
@@ -99,6 +110,7 @@ export function createTransport(baseUrl: string): GatewayClient {
async reportLocalEval(counts) { async reportLocalEval(counts) {
if (!token) throw new Error('reportLocalEval: no session'); if (!token) throw new Error('reportLocalEval: no session');
assertOnline();
// keepalive so a batch flushed as the app is backgrounded still reaches the edge. // keepalive so a batch flushed as the app is backgrounded still reaches the edge.
const res = await fetch(`${origin}/metrics/local-eval`, { const res = await fetch(`${origin}/metrics/local-eval`, {
method: 'POST', method: 'POST',
@@ -319,6 +331,8 @@ export function createTransport(baseUrl: string): GatewayClient {
}, },
subscribe(onEvent, onError) { subscribe(onEvent, onError) {
// No live stream in offline mode (the kill switch); return an inert unsubscribe.
if (offlineMode.active) return () => {};
const ctrl = new AbortController(); const ctrl = new AbortController();
void (async () => { void (async () => {
try { try {
+2 -1
View File
@@ -2,6 +2,7 @@
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte'; import { app } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { aboutContent } from '../lib/aboutContent'; import { aboutContent } from '../lib/aboutContent';
import { onExternalLinkClick } from '../lib/links'; import { onExternalLinkClick } from '../lib/links';
@@ -35,7 +36,7 @@
<p class="muted">{t('about.version', { v: version })}</p> <p class="muted">{t('about.version', { v: version })}</p>
{#if !(app.profile?.isGuest ?? true)} {#if !(app.profile?.isGuest ?? true)}
<button class="feedback" onclick={() => navigate('/feedback')}> <button class="feedback" disabled={offlineMode.active} onclick={() => navigate('/feedback')}>
{t('feedback.title')}{#if app.feedbackReplyUnread}<span class="fbadge">1</span>{/if} {t('feedback.title')}{#if app.feedbackReplyUnread}<span class="fbadge">1</span>{/if}
</button> </button>
{/if} {/if}
+6 -2
View File
@@ -74,7 +74,9 @@
seed: randomSeed(), seed: randomSeed(),
multipleWords: multipleWordsForRequest(v, multipleWords), multipleWords: multipleWordsForRequest(v, multipleWords),
seats: [ seats: [
{ kind: 'human', name: app.profile?.displayName ?? 'You' }, // The human seat carries the real account id so the game screen and the lobby identify
// "you" and the correct turn exactly as for an online game (the robot uses a synthetic id).
{ kind: 'human', name: app.profile?.displayName ?? 'You', accountId: app.session?.userId },
{ kind: 'robot', name: 'Robot' }, { kind: 'robot', name: 'Robot' },
], ],
}); });
@@ -110,7 +112,9 @@
); );
onMount(async () => { onMount(async () => {
if (guest) return; // Offline mode offers only the local vs_ai flow (the friends section is hidden), and the network
// is off, so skip the friend fetch — it would be refused by the transport and toast an error.
if (guest || offlineMode.active) return;
try { try {
friends = await gateway.friendsList(); friends = await gateway.friendsList();
} catch (e) { } catch (e) {
+9 -2
View File
@@ -6,6 +6,7 @@
import Friends from './Friends.svelte'; import Friends from './Friends.svelte';
import About from './About.svelte'; import About from './About.svelte';
import { app } from '../lib/app.svelte'; import { app } from '../lib/app.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile / // The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
@@ -23,6 +24,12 @@
$effect(() => { $effect(() => {
if (guest && tab === 'friends') tab = 'settings'; if (guest && tab === 'friends') tab = 'settings';
}); });
// Offline mode has no network, so the Profile and Friends surfaces (which read/save over the
// network) are disabled — fall back to Settings if the hub is on one (a deep-link or a live flip).
// Settings stays reachable: it holds the online/offline toggle.
$effect(() => {
if (offlineMode.active && (tab === 'profile' || tab === 'friends')) tab = 'settings';
});
const titleKey: Record<SettingsTab, MessageKey> = { const titleKey: Record<SettingsTab, MessageKey> = {
settings: 'settings.title', settings: 'settings.title',
@@ -48,11 +55,11 @@
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}> <button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
<span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></span> <span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></span>
</button> </button>
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}> <button class="tab" class:active={tab === 'profile'} disabled={offlineMode.active} onclick={() => (tab = 'profile')}>
<span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></span> <span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></span>
</button> </button>
{#if !guest} {#if !guest}
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}> <button class="tab" class:active={tab === 'friends'} disabled={offlineMode.active} onclick={() => (tab = 'friends')}>
<span class="face"><span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span></span> <span class="face"><span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span></span>
</button> </button>
{/if} {/if}