30770a759b
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m44s
Flipping the Settings toggle to offline now checks that every enabled variant's dictionary is on the device before entering offline mode: it fetches missing ones cache-first and waits up to ~5 s (raceOfflineReady + the lazy dict/offlineready), greying the toggle meanwhile. If they cannot be readied in time it stays online and shows a 'needs internet' note, while the fetch keeps warming the cache in the background so a later flip is instant. Leaving offline is never gated. Prevents entering a half-baked offline mode (no dawg -> cannot create/play a local game) when the background preload has not finished (poor connection, or an immediate flip right after install). - offline.ts: raceOfflineReady (pure, injected sleep; unit-tested red->green) - dict/offlineready.ts: ensureOfflineDicts (cache-first preloadDicts, lazy chunk) - offline.svelte.ts: requestOffline + TOGGLE_READY_BUDGET_MS - Settings.svelte: checking/needsData state, disabled toggle, inline note - i18n: settings.offlineChecking / settings.offlineNeedsData (en+ru) - docs: FUNCTIONAL(+_ru) offline story + ARCHITECTURE offline paragraph
114 lines
5.4 KiB
TypeScript
114 lines
5.4 KiB
TypeScript
// The deliberate offline MODE: a sticky, device-scoped reactive flag the app reads to gate the
|
|
// network, tint the chrome blue and show only local games. It is the player's own choice (the
|
|
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient
|
|
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts.
|
|
|
|
import { loadOfflinePref, saveOfflinePref, offlinePreloadEligible } from './offline';
|
|
import { isStandalone } from './pwa';
|
|
import { insideTelegram } from './telegram';
|
|
import { insideVK } from './vk';
|
|
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 offline flags; read them in markup / $derived. */
|
|
export const offlineMode = {
|
|
/** 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;
|
|
},
|
|
};
|
|
|
|
/**
|
|
* setOfflineMode enters or leaves offline mode. By default it persists the choice (device-scoped) —
|
|
* a deliberate choice (the Settings toggle, or the cold-start "no connection" dialog). Pass
|
|
* persist=false for a transient, auto-detected offline (a cold start with no network interface): the
|
|
* flag holds for the session but is not saved, so the next launch re-evaluates the network.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/** The toggle-flip readiness wait: entering offline waits at most this long for the enabled
|
|
* variants' dictionaries before reverting to online (the fetch then continues in the background). */
|
|
export const TOGGLE_READY_BUDGET_MS = 5000;
|
|
|
|
/**
|
|
* requestOffline attempts to enter offline mode from the Settings toggle. It fetches the enabled
|
|
* variants' dictionaries cache-first and, if every one is available within the readiness budget,
|
|
* switches to a deliberate (persisted) offline mode and returns true. Otherwise it stays online and
|
|
* returns false — the caller shows the "needs internet" note — while the fetch keeps warming the
|
|
* cache so a later flip is instant. The readiness glue and the dict loader are imported dynamically,
|
|
* so neither is pulled into the main bundle. Returns false with no profile.
|
|
*/
|
|
export async function requestOffline(prof: Profile | null, budgetMs = TOGGLE_READY_BUDGET_MS): Promise<boolean> {
|
|
if (!prof) return false;
|
|
const m = await import('./dict/offlineready');
|
|
const ready = await m.ensureOfflineDicts(prof, budgetMs);
|
|
if (ready) setOfflineMode(true);
|
|
return ready;
|
|
}
|
|
|
|
// The dict-preload warning: true when a first-lobby background preload could not fetch every
|
|
// enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete.
|
|
// The lobby shows a notice in place of the ad banner while it holds.
|
|
let preloadWarn = $state(false);
|
|
|
|
/** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */
|
|
export const dictPreloadWarning = {
|
|
/** active is true while a first-lobby dictionary preload has left a variant unavailable. */
|
|
get active(): boolean {
|
|
return preloadWarn;
|
|
},
|
|
};
|
|
|
|
/** setDictPreloadWarning raises or clears the in-lobby preload-failure notice. */
|
|
export function setDictPreloadWarning(on: boolean): void {
|
|
preloadWarn = on;
|
|
}
|
|
|
|
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby
|
|
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack.
|
|
let preloadInFlight = false;
|
|
|
|
/**
|
|
* kickDictPreload starts a background preload of the enabled variants' dictionaries for an
|
|
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later
|
|
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain
|
|
* browser tab, without a confirmed email, while offline, or when a preload is already running;
|
|
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a
|
|
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader
|
|
* and generator are imported dynamically, so neither is pulled into the main bundle.
|
|
*/
|
|
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
|
|
if (preloadInFlight || !prof) return;
|
|
const eligible = offlinePreloadEligible({
|
|
hasEmail: !!prof.email,
|
|
standalone: isStandalone(),
|
|
inTelegram: insideTelegram(),
|
|
inVK: insideVK(),
|
|
online: typeof navigator === 'undefined' || navigator.onLine !== false,
|
|
});
|
|
if (!eligible) return;
|
|
preloadInFlight = true;
|
|
void import('./dict/preloadrun')
|
|
.then((m) => m.runPreload(prof, warnOnFail))
|
|
.catch(() => {
|
|
/* best-effort warmup — a failed dynamic import just leaves offline data unprimed */
|
|
})
|
|
.finally(() => {
|
|
preloadInFlight = false;
|
|
});
|
|
}
|