release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212

Merged
developer merged 79 commits from development into master 2026-07-07 14:40:43 +00:00
5 changed files with 71 additions and 3 deletions
Showing only changes of commit 54d701fd8a - Show all commits
+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
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
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
`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
+28
View File
@@ -43,6 +43,9 @@ import { parseStartParam } from './deeplink';
import {
clearOnboarding,
clearSession,
clearProfile,
loadProfile,
saveProfile,
loadOnboarding,
loadPrefs,
loadSession,
@@ -53,6 +56,8 @@ import {
} from './session';
import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { offlineMode, setOfflineMode } from './offline.svelte';
import { shouldBootOffline } from './offline';
import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta';
@@ -460,6 +465,7 @@ export async function refreshProfile(): Promise<void> {
if (!app.session) return;
try {
app.profile = await gateway.profileGet();
void saveProfile(app.profile); // keep the offline-boot cache fresh
} catch {
// Best-effort; the banner just stays as it was until the next fetch.
}
@@ -517,6 +523,9 @@ async function adoptSession(s: Session): Promise<void> {
await saveSession(s);
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);
// 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
@@ -790,6 +799,24 @@ export async function bootstrap(): Promise<void> {
// and skipped in the mock build; see lib/pwa.svelte.
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 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.
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;
}
setOfflineMode(false);
}
const saved = await loadSession();
// 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).
@@ -1023,6 +1050,7 @@ export async function logout(): Promise<void> {
app.splashDone = false;
gateway.setToken(null);
await clearSession();
await clearProfile();
app.session = null;
app.profile = null;
navigate('/login');
+8 -1
View File
@@ -1,5 +1,5 @@
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';
// 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, 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 {
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 —
// 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 { Locale } from './i18n/catalog';
import type { BoardLabelMode } from './boardlabels';
@@ -119,6 +119,25 @@ export function clearSession(): Promise<void> {
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 {
theme: ThemePref;
locale: Locale;