Merge pull request 'feat(offline): gate the offline toggle on dictionary readiness' (#205) from feature/offline-toggle-readiness 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 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
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 #205.
This commit is contained in:
@@ -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. A **cold launch already in offline mode** boots from the persisted session and
|
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||||
|
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||||
|
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||||
|
fetch finishes in the background (the next flip is then instant). 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
|
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
|
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
|
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||||
|
|||||||
+5
-2
@@ -265,8 +265,11 @@ Only **vs_ai** games are playable — New Game creates a device-local game again
|
|||||||
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
||||||
offline mode, and never sync to the account. So a game can be created and played with no connection,
|
offline mode, and never sync to the account. So a game can be created and played with no connection,
|
||||||
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
|
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
|
||||||
(once installed) launches from a precached shell even with no network. The mode is device-scoped and
|
(once installed) launches from a precached shell even with no network. Switching **to** offline first
|
||||||
sticky across launches.
|
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
|
||||||
|
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
|
||||||
|
(the download keeps running in the background, so a later switch is instant). The mode is
|
||||||
|
device-scoped and sticky across launches.
|
||||||
|
|
||||||
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
|
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
|
||||||
with no connection it switches to offline for that session; when the device is online but the gateway
|
with no connection it switches to offline for that session; when the device is online but the gateway
|
||||||
|
|||||||
@@ -270,8 +270,11 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||||
запускается из прекешированного шелла даже без сети. Режим привязан к устройству и сохраняется между
|
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||||
запусками.
|
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||||
|
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||||
|
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||||
|
сохраняется между запусками.
|
||||||
|
|
||||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Browser orchestration for the offline-toggle readiness wait. Lazily imported by
|
||||||
|
// offline.svelte.ts's requestOffline so the dict loader/generator it pulls in stays out of the main
|
||||||
|
// bundle. It runs the same cache-first preload as the background warmup (preload.ts), but bounded by
|
||||||
|
// a UI wait: it tells the toggle whether flipping to offline can succeed right now, and leaves the
|
||||||
|
// fetch running on a timeout so a later flip is instant.
|
||||||
|
|
||||||
|
import type { Profile } from '../model';
|
||||||
|
import { preloadDicts } from './preload';
|
||||||
|
import { getDawg, dictLoadingDisabled } from '../dict';
|
||||||
|
import { raceOfflineReady } from '../offline';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureOfflineDicts fetches the profile's enabled variants' dictionaries cache-first (instant when
|
||||||
|
* already warm, a network fetch otherwise) and reports, within budgetMs, whether every one is
|
||||||
|
* available — the offline toggle's readiness gate. With no enabled variant there is nothing to play
|
||||||
|
* offline, so it is never ready. The fetch is not aborted on a timeout; it keeps warming the
|
||||||
|
* on-device cache in the background so a later flip to offline succeeds immediately.
|
||||||
|
*/
|
||||||
|
export async function ensureOfflineDicts(prof: Profile, budgetMs: number): Promise<boolean> {
|
||||||
|
if (prof.variantPreferences.length === 0) return false;
|
||||||
|
const run = preloadDicts(prof.dictVersions, prof.variantPreferences, {
|
||||||
|
getDawg,
|
||||||
|
disabled: dictLoadingDisabled,
|
||||||
|
retries: 1,
|
||||||
|
});
|
||||||
|
return raceOfflineReady(run, budgetMs);
|
||||||
|
}
|
||||||
@@ -217,6 +217,8 @@ export const en = {
|
|||||||
'settings.offlineMode': 'Play mode',
|
'settings.offlineMode': 'Play mode',
|
||||||
'settings.online': 'Online',
|
'settings.online': 'Online',
|
||||||
'settings.offline': 'Offline',
|
'settings.offline': 'Offline',
|
||||||
|
'settings.offlineChecking': 'Loading dictionaries…',
|
||||||
|
'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.',
|
||||||
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
|
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
|
||||||
'offline.promptTitle': 'No connection. Enable offline mode?',
|
'offline.promptTitle': 'No connection. Enable offline mode?',
|
||||||
'offline.promptYes': 'Enable',
|
'offline.promptYes': 'Enable',
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'settings.offlineMode': 'Режим игры',
|
'settings.offlineMode': 'Режим игры',
|
||||||
'settings.online': 'Онлайн',
|
'settings.online': 'Онлайн',
|
||||||
'settings.offline': 'Оффлайн',
|
'settings.offline': 'Оффлайн',
|
||||||
|
'settings.offlineChecking': 'Загрузка словарей…',
|
||||||
|
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
|
||||||
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
|
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
|
||||||
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
|
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
|
||||||
'offline.promptYes': 'Включить',
|
'offline.promptYes': 'Включить',
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ export function setOfflineMode(on: boolean, persist = true): void {
|
|||||||
if (persist) saveOfflinePref(on);
|
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
|
// 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.
|
// 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.
|
// The lobby shows a notice in place of the ad banner while it holds.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline } from './offline';
|
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } 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).
|
||||||
@@ -53,3 +53,25 @@ describe('offline mode helpers', () => {
|
|||||||
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
|
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('raceOfflineReady (toggle-flip readiness wait)', () => {
|
||||||
|
// A sleep that never elapses: the fetch always wins the race, exercising its resolution.
|
||||||
|
const never = (): Promise<void> => new Promise<void>(() => {});
|
||||||
|
// A sleep that elapses immediately: the budget always wins the race, exercising the timeout.
|
||||||
|
const now = (): Promise<void> => Promise.resolve();
|
||||||
|
|
||||||
|
it('is ready when the fetch resolves with nothing failed before the budget', async () => {
|
||||||
|
const run = Promise.resolve({ failed: [] as Variant[] });
|
||||||
|
expect(await raceOfflineReady(run, 5000, never)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not ready when a variant is still missing after the fetch', async () => {
|
||||||
|
const run = Promise.resolve({ failed: ['erudit_ru'] as Variant[] });
|
||||||
|
expect(await raceOfflineReady(run, 5000, never)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not ready when the budget elapses before the fetch resolves', async () => {
|
||||||
|
const run = new Promise<{ failed: Variant[] }>(() => {}); // never resolves
|
||||||
|
expect(await raceOfflineReady(run, 5000, now)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant)
|
|||||||
return enabled.filter((v) => !hasDict(v));
|
return enabled.filter((v) => !hasDict(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* raceOfflineReady runs the dictionary fetch `run` against a `budgetMs` wait and reports whether
|
||||||
|
* offline mode can be entered now: ready only when the fetch resolves with nothing still failed
|
||||||
|
* before the budget elapses. On a timeout the caller stops waiting but does NOT abort `run` — it
|
||||||
|
* keeps warming the on-device cache so a later flip to offline is instant. The sleep is injected so
|
||||||
|
* the logic stays pure and unit-tests in the node env.
|
||||||
|
*/
|
||||||
|
export async function raceOfflineReady(
|
||||||
|
run: Promise<{ failed: readonly unknown[] }>,
|
||||||
|
budgetMs: number,
|
||||||
|
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||||
|
): Promise<boolean> {
|
||||||
|
const elapsed = sleep(budgetMs).then(() => null);
|
||||||
|
const res = await Promise.race([run, elapsed]);
|
||||||
|
return res !== null && res.failed.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* offlinePreloadEligible reports whether a background dictionary preload should run in this
|
* offlinePreloadEligible reports whether a background dictionary preload should run in this
|
||||||
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
|
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import type { BoardLabelMode } from '../lib/boardlabels';
|
import type { BoardLabelMode } from '../lib/boardlabels';
|
||||||
import { insideTelegram } from '../lib/telegram';
|
import { insideTelegram } from '../lib/telegram';
|
||||||
import { insideVK } from '../lib/vk';
|
import { insideVK } from '../lib/vk';
|
||||||
import { offlineMode, setOfflineMode } from '../lib/offline.svelte';
|
import { offlineMode, setOfflineMode, requestOffline } from '../lib/offline.svelte';
|
||||||
import { isStandalone } from '../lib/pwa';
|
import { isStandalone } from '../lib/pwa';
|
||||||
import InstallApp from '../components/InstallApp.svelte';
|
import InstallApp from '../components/InstallApp.svelte';
|
||||||
|
|
||||||
@@ -35,6 +35,25 @@
|
|||||||
classic: 'settings.labelsClassic',
|
classic: 'settings.labelsClassic',
|
||||||
none: 'settings.labelsNone',
|
none: 'settings.labelsNone',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The offline toggle gates entry on dictionary readiness: flipping to offline fetches the enabled
|
||||||
|
// variants' dictionaries (cache-first) and waits at most a few seconds; if they cannot be made
|
||||||
|
// available it stays online and shows the "needs internet" note, while the fetch keeps warming the
|
||||||
|
// cache in the background so a later flip is instant. Leaving offline (-> online) is never gated.
|
||||||
|
let checking = $state(false);
|
||||||
|
let needsData = $state(false);
|
||||||
|
|
||||||
|
async function goOffline(): Promise<void> {
|
||||||
|
if (offlineMode.active || checking) return;
|
||||||
|
needsData = false;
|
||||||
|
checking = true;
|
||||||
|
try {
|
||||||
|
const ready = await requestOffline(app.profile);
|
||||||
|
if (!ready) needsData = true;
|
||||||
|
} finally {
|
||||||
|
checking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
@@ -89,13 +108,26 @@
|
|||||||
<section>
|
<section>
|
||||||
<h3>{t('settings.offlineMode')}</h3>
|
<h3>{t('settings.offlineMode')}</h3>
|
||||||
<div class="seg">
|
<div class="seg">
|
||||||
<button class="opt" class:active={!offlineMode.active} onclick={() => setOfflineMode(false)}>
|
<button
|
||||||
|
class="opt"
|
||||||
|
class:active={!offlineMode.active}
|
||||||
|
disabled={checking}
|
||||||
|
onclick={() => {
|
||||||
|
needsData = false;
|
||||||
|
setOfflineMode(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('settings.online')}
|
{t('settings.online')}
|
||||||
</button>
|
</button>
|
||||||
<button class="opt" class:active={offlineMode.active} onclick={() => setOfflineMode(true)}>
|
<button class="opt" class:active={offlineMode.active} disabled={checking} onclick={goOffline}>
|
||||||
{t('settings.offline')}
|
{t('settings.offline')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if checking}
|
||||||
|
<p class="onote">{t('settings.offlineChecking')}</p>
|
||||||
|
{:else if needsData}
|
||||||
|
<p class="onote onote--warn" role="alert">{t('settings.offlineNeedsData')}</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -139,6 +171,18 @@
|
|||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.opt:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.onote {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
.onote--warn {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user