Compare commits

...

1 Commits

Author SHA1 Message Date
Ilia Denisov 990cb4a68d feat(pwa): installable web app + landing web entry
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Failing after 11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Make the web SPA an installable PWA and surface it to users:

- manifest.webmanifest + an install-only service worker + 192/512/maskable
  icons (ui/public); PWA head tags in index.html; the .webmanifest MIME type
  registered in the gateway (the distroless image has no /etc/mime.types).
- Platform-adaptive install CTA (components/InstallApp.svelte + lib/pwa):
  one-tap on Chromium, manual Add-to-Home-Screen instructions on iOS Safari,
  hidden elsewhere / once installed / inside a Mini App. Shown under the
  logged-out login card and at the bottom of Settings.
- The landing gains a third entry linking /app/ (the brand tile), with a
  caption under all three (Telegram / VK / Веб-версия).

The service worker is navigation-only (network-first, cached-shell fallback);
hashed assets and the Connect stream are untouched. It exists to satisfy
Chromium's installability requirement and is the single growth point for a
future opt-in offline mode.

Tests: pwa.ts unit tests; a webui probe (manifest/sw.js content-type + the
SPA-fallback-to-HTML trap); e2e for the one-tap CTA, the iOS instructions
modal and the landing web entry. Docs: ARCHITECTURE §13, FUNCTIONAL (+ru),
gateway README.
2026-07-05 21:05:52 +02:00
25 changed files with 657 additions and 25 deletions
+8 -1
View File
@@ -1233,7 +1233,14 @@ origin, so the test contour canonicalises to production instead of being indexed
separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no
browser-language detection — crawlers render with arbitrary languages). The favicon set,
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from
`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's
installability requirement (a registered SW, needed for install on Android) and is the single
growth point for a future opt-in offline mode. 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
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
+7 -1
View File
@@ -21,7 +21,8 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats.
A public **landing page** at the site root introduces the game, switches language and
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
theme, and links to the Telegram game channel, the VK Mini App and the **web version**
(`/app/`) — each under a caption (Telegram / VK / Веб-версия); the game itself runs at
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
(it follows the system scheme, not the saved preference); its language choice is saved. The
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
@@ -31,6 +32,11 @@ nondeterministic); a saved 🌐 choice still wins. The page carries a static Rus
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
shell is `noindex` — the landing is the only indexable page.
On the plain web the client is an **installable PWA**: a logged-out player sees an install
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
home screen / desktop in one tap where the browser supports it (Chromium), or shows manual
Add-to-Home-Screen steps on iOS Safari; it is hidden once installed and inside the Mini Apps.
### First-run onboarding
The first time a player opens the app it walks them through the interface with a light
**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a
+7 -1
View File
@@ -22,7 +22,8 @@ top-1 подсказку, безлимитную проверку слова с
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов.
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
тему и ведёт в Telegram-канал игры, в VK Mini App и в **веб-версию** (`/app/`) — каждая под
подписью (Telegram / VK / Веб-версия); сама игра живёт по адресам
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
открывается на **русском** — язык браузера не определяется автоматически (роботы
@@ -33,6 +34,11 @@ Open Graph, которую используют превью ссылок в Tel
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница.
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
(домашний экран) там, где браузер это умеет (Chromium), либо показывает ручные шаги
«На экран „Домой“» в Safari на iOS; он скрыт после установки и внутри Mini App.
### Первый запуск: онбординг
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
+3
View File
@@ -8,6 +8,9 @@ backend over REST/JSON, and bridges the backend's gRPC push stream to each
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (with the `.webmanifest`
MIME type registered in-process — the distroless image has no `/etc/mime.types`) and the
install-only `sw.js`, both shipped unhashed from `ui/public/`.
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
in the deployed contour the front caddy owns `/_gm` (see
+19 -6
View File
@@ -17,6 +17,7 @@ package webui
import (
"embed"
"io/fs"
"mime"
"net/http"
"path"
"strings"
@@ -35,13 +36,25 @@ func distFS() fs.FS {
return sub
}
// Handler serves the embedded UI. An existing file is served directly (hash-named assets get
// an immutable cache); every other path falls back to indexName (the SPA shell) so a
// client-side deep link still loads. When stripPrefix is non-empty it is removed from the
// request path before lookup, so the same build serves under a sub-path (e.g. "/app/" or
// "/telegram/").
// init registers the MIME type for .webmanifest, which Go's built-in table lacks and the
// distroless runtime image has no /etc/mime.types to supply. Without it the PWA Web App Manifest
// served under /app/ would be content-sniffed to text/plain, which some browsers reject.
func init() {
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
}
// Handler serves the compile-time embedded UI build over the public edge — the game SPA under
// /app/, /telegram/ and /vk/. It delegates to handlerFor over the embedded dist/.
func Handler(stripPrefix, indexName string) http.Handler {
content := distFS()
return handlerFor(distFS(), stripPrefix, indexName)
}
// handlerFor serves content as the UI: an existing file is served directly (hash-named assets get
// an immutable cache); every other path falls back to indexName (the SPA shell) so a client-side
// deep link still loads. When stripPrefix is non-empty it is removed from the request path before
// lookup, so the same build serves under a sub-path (e.g. "/app/" or "/telegram/"). Split from
// Handler so it can be exercised over an in-memory fs.FS in tests.
func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler {
files := http.FileServer(http.FS(content))
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
+54
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
)
// get drives the handler with a GET for the given path and returns the response.
@@ -55,3 +56,56 @@ func TestAppMountServesShellStripsPrefixAndCachesAssets(t *testing.T) {
}
}
}
// testFS mirrors what Vite emits into dist/: the SPA shell, the PWA manifest + service worker at
// the root (copied from ui/public), and a hash-named asset. The compile-time embedded dist/ holds
// only a placeholder shell, so the manifest/sw.js serving is exercised over this in-memory FS.
func testFS() fstest.MapFS {
return fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><title>shell</title>")},
"manifest.webmanifest": {Data: []byte(`{"name":"Эрудит"}`)},
"sw.js": {Data: []byte("self.addEventListener('fetch', () => {});")},
"assets/app-abc123.js": {Data: []byte("export const x = 1;")},
}
}
// TestHandlerServesManifestWithManifestType: the PWA manifest is served with the manifest MIME
// type (registered in init, since the distroless image has no /etc/mime.types), not sniffed to
// text/plain, which some browsers reject.
func TestHandlerServesManifestWithManifestType(t *testing.T) {
h := handlerFor(testFS(), "/app/", "index.html")
resp := get(t, h, "/app/manifest.webmanifest")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/manifest+json") {
t.Errorf("Content-Type = %q, want application/manifest+json", ct)
}
}
// TestHandlerServesServiceWorkerAsJavaScript: sw.js is served as JavaScript from the root, so its
// registration (scope /app/) succeeds.
func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) {
h := handlerFor(testFS(), "/app/", "index.html")
resp := get(t, h, "/app/sw.js")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
t.Errorf("Content-Type = %q, want text/javascript", ct)
}
}
// TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted
// into dist/ falls back to the SPA shell as text/html, on which SW registration would fail. The two
// tests above pin that the real files are served instead of the shell.
func TestHandlerFallsBackToShellForUnknownManifestPath(t *testing.T) {
h := handlerFor(testFS(), "/app/", "index.html")
resp := get(t, h, "/app/not-emitted.webmanifest")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html (SPA shell fallback)", ct)
}
}
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from './fixtures';
// The install CTA (components/InstallApp.svelte) is platform-adaptive: a one-tap button where the
// browser offers a beforeinstallprompt (Chromium), a manual instructions modal on iOS Safari, and
// nothing elsewhere / once installed / inside a Mini App. The native OS install dialog is
// browser-level and not drivable from Playwright, so these assert the CTA's presence and branch.
const IPHONE_SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
test('web: a captured install prompt shows the one-tap CTA and clicking it prompts', async ({ page }) => {
await page.goto('/');
// The web login screen is shown (an ordinary browser tab, not a Mini App).
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
// Hidden until the browser offers installation (Playwright never fires beforeinstallprompt).
await expect(page.getByRole('button', { name: /Install the app/i })).toHaveCount(0);
// Simulate Chromium offering the install: dispatch a beforeinstallprompt the app can capture.
await page.evaluate(() => {
const e = new Event('beforeinstallprompt') as Event & {
prompt?: () => Promise<void>;
userChoice?: Promise<unknown>;
};
(window as unknown as { __promptCalled: boolean }).__promptCalled = false;
e.prompt = () => {
(window as unknown as { __promptCalled: boolean }).__promptCalled = true;
return Promise.resolve();
};
e.userChoice = Promise.resolve({ outcome: 'accepted', platform: '' });
window.dispatchEvent(e);
});
const cta = page.getByRole('button', { name: /Install the app/i });
await expect(cta).toBeVisible();
await cta.click();
// Clicking calls the captured prompt (the native dialog itself is not observable here).
await expect
.poll(() => page.evaluate(() => (window as unknown as { __promptCalled: boolean }).__promptCalled))
.toBe(true);
});
test.describe('iOS Safari', () => {
test.use({ userAgent: IPHONE_SAFARI });
test('shows manual Add-to-Home-Screen instructions (no programmatic install)', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
// On iOS Safari the CTA shows at once (no beforeinstallprompt is ever fired there).
const cta = page.getByRole('button', { name: /Install the app/i });
await expect(cta).toBeVisible();
await cta.click();
// It opens the app's own instructions modal (not a native / Telegram popup).
await expect(page.getByRole('heading', { name: 'Add to Home Screen' })).toBeVisible();
await expect(page.getByText(/Tap the Share button/)).toBeVisible();
});
});
+12
View File
@@ -46,3 +46,15 @@ test('the landing is a normal scrolling document (the SPA document-pin does not
expect(state.shell).toBe(false);
expect(state.bodyPosition).not.toBe('fixed');
});
// The web-version entry (/app/) is always present, alongside the build-var-gated Telegram/VK ones
// (absent here, since e2e sets no VITE_* vars), each with a caption.
test('the landing shows a web-version entry linking /app/, with a caption', async ({ page }) => {
await page.goto('/landing.html');
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default
const web = page.getByRole('link', { name: 'Играть в браузере' });
await expect(web).toBeVisible();
expect(await web.getAttribute('href')).toContain('/app/');
await expect(page.getByText('Веб-версия')).toBeVisible();
});
+12
View File
@@ -256,6 +256,18 @@
<link rel="icon" href="favicon.ico" sizes="32x32" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<!-- PWA installability (web /app/ only): the manifest + a service worker (registered from
lib/pwa, web-only) let the app be added to the home screen / desktop. The relative href
resolves per serving path; these tags are inert inside the Telegram/VK Mini App webviews,
which manage their own chrome. theme-color mirrors the light/dark --bg tokens (app.css). -->
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#f3f4f6" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0f1115" media="(prefers-color-scheme: dark)" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Эрудит" />
<meta name="application-name" content="Эрудит" />
</head>
<body>
<div id="app"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"name": "Эрудит (Скрэббл)",
"short_name": "Эрудит",
"description": "Игра в слова «Эрудит» (Скрэббл): играйте с друзьями, случайным соперником или ИИ-роботом.",
"lang": "ru",
"dir": "ltr",
"start_url": ".",
"scope": ".",
"display": "standalone",
"background_color": "#f3f4f6",
"theme_color": "#f3f4f6",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+53
View File
@@ -0,0 +1,53 @@
// Install-only service worker.
//
// Its sole job today is to satisfy Chromium's PWA installability requirement (a registered
// service worker with a fetch handler) so the web app can be installed to the home screen /
// desktop — notably on Android, where the manifest alone is not enough. It deliberately does
// NOT cache assets or the Connect-RPC stream: only top-level navigations are handled,
// network-first with a cached-shell fallback, so the live app, its immutable hashed assets and
// the live event stream are never served stale.
//
// This is the single designated growth point for the planned offline mode (a Settings toggle,
// default online — see docs/ARCHITECTURE.md). Extend the fetch router here; keep the same
// /app/ scope; gate any real caching behind the toggle and coordinate updates with the boot
// version guard.
const SHELL = 'scrabble-shell-v1'; // bump only when the SW strategy itself changes.
self.addEventListener('install', () => {
// No cached content to migrate carefully, so activate the new worker immediately.
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)));
await self.clients.claim();
})(),
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
// Only top-level navigations are handled. Hashed assets and the Connect-RPC requests are
// left untouched (no respondWith -> the browser performs its default fetch), so the worker
// can never break the live stream or serve a stale immutable asset.
if (req.mode !== 'navigate') return;
event.respondWith(
(async () => {
try {
const res = await fetch(req);
const cache = await caches.open(SHELL);
// One shell entry regardless of the deep-link path: the hash router resolves the route
// client-side, so any offline navigation can be served the same cached shell.
cache.put('shell', res.clone());
return res;
} catch {
const cache = await caches.open(SHELL);
const cached = await cache.match('shell');
return cached ?? Response.error();
}
})(),
);
});
+31 -16
View File
@@ -80,20 +80,26 @@
<section class="hero">
<h1>{about.title}</h1>
<p class="tagline">{t('landing.tagline')}</p>
{#if tgLink || vkLink}
<div class="entries">
{#if tgLink}
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
<img src="telegram-logo.svg" alt="" width="64" height="64" />
</a>
{/if}
{#if vkLink}
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
<img src="vk-logo.svg" alt="" width="64" height="64" />
</a>
{/if}
</div>
{/if}
<!-- Platform entries: Telegram/VK are gated by their build vars; the web version (/app/) is
always available. A caption under each clarifies what it opens. -->
<div class="entries">
{#if tgLink}
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
<img src="telegram-logo.svg" alt="" width="64" height="64" />
<span class="caption">{t('landing.captionTelegram')}</span>
</a>
{/if}
{#if vkLink}
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
<img src="vk-logo.svg" alt="" width="64" height="64" />
<span class="caption">{t('landing.captionVK')}</span>
</a>
{/if}
<a class="entry" href="/app/" aria-label={t('landing.playWeb')}>
<img src="favicon.svg" alt="" width="64" height="64" />
<span class="caption">{t('landing.captionWeb')}</span>
</a>
</div>
</section>
<section class="info">
@@ -207,8 +213,8 @@
color: var(--text-muted);
font-size: 1.05rem;
}
/* The platform entries are just the bigger logos in a row (no button chrome, no captions);
each link keeps an aria-label for assistive tech. */
/* The platform entries are the bigger logos in a row, each with a short caption below; the
link keeps an aria-label for assistive tech. */
.entries {
align-self: center;
display: flex;
@@ -217,6 +223,11 @@
}
.entry {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-decoration: none;
color: var(--text);
}
.entry img {
display: block;
@@ -225,6 +236,10 @@
.entry:hover img {
transform: scale(1.06);
}
.caption {
font-size: 0.9rem;
color: var(--text-muted);
}
.info {
background: var(--surface-2);
border-radius: var(--radius-sm);
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import { t } from '../lib/i18n/index.svelte';
import { installMode, promptInstall } from '../lib/pwa.svelte';
import Modal from './Modal.svelte';
// Platform-adaptive: 'oneTap' fires the native Chromium install dialog; 'iosInstructions' opens
// the manual Share -> Add-to-Home-Screen guide (iOS Safari has no programmatic install); 'hidden'
// renders nothing (already installed, inside a Telegram/VK Mini App, or an unsupported browser).
const mode = $derived(installMode());
let showIos = $state(false);
function onClick(): void {
// promptInstall must run synchronously from this gesture — the deferred prompt is gated on it.
if (mode === 'oneTap') void promptInstall();
else if (mode === 'iosInstructions') showIos = true;
}
</script>
{#if mode !== 'hidden'}
<button type="button" class="install" onclick={onClick}>
<img src="favicon.svg" alt="" width="40" height="40" />
<span class="text">
<span class="title">{t('install.title')}</span>
<span class="subtitle">{t('install.subtitle')}</span>
</span>
</button>
{/if}
{#if showIos}
<Modal title={t('install.iosTitle')} onclose={() => (showIos = false)}>
<ol class="steps">
<li>{t('install.iosStep1')}</li>
<li>{t('install.iosStep2')}</li>
<li>{t('install.iosStep3')}</li>
</ol>
<button type="button" class="ok" onclick={() => (showIos = false)}>{t('common.ok')}</button>
</Modal>
{/if}
<style>
.install {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.install img {
flex: none;
border-radius: 8px;
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
font-weight: 600;
}
.subtitle {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.35;
}
.steps {
margin: 0 0 16px;
padding-left: 1.25rem;
line-height: 1.6;
}
.ok {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
</style>
+6
View File
@@ -35,6 +35,7 @@ import {
} from './telegram';
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import { pendingVKLink, type VKLinkCallback } from './vkid';
import { registerServiceWorker } from './pwa.svelte';
import { haptic } from './haptics';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink';
@@ -783,6 +784,11 @@ export async function bootstrap(): Promise<void> {
return;
}
// On the plain web (both Mini-App branches returned above) register the install-only service
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
// and skipped in the mock build; see lib/pwa.svelte.
registerServiceWorker();
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).
+11
View File
@@ -234,6 +234,17 @@ export const en = {
'landing.tagline': 'Play Scrabble with friends, random opponent or an AI.',
'landing.playTelegram': 'Play in Telegram',
'landing.playVK': 'Play on VK',
'landing.playWeb': 'Play in your browser',
'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK',
'landing.captionWeb': 'Web',
'install.title': 'Install the app',
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
'install.iosTitle': 'Add to Home Screen',
'install.iosStep1': 'Tap the Share button in the browser toolbar.',
'install.iosStep2': 'Choose “Add to Home Screen”.',
'install.iosStep3': 'Tap “Add”.',
'lang.en': 'English',
'lang.ru': 'Русский',
+11
View File
@@ -234,6 +234,17 @@ export const ru: Record<MessageKey, string> = {
'landing.tagline': 'Играй в «Эрудита» с друзьями, случайным соперником или ИИ-роботом.',
'landing.playTelegram': 'Играть в Telegram',
'landing.playVK': 'Играть во ВКонтакте',
'landing.playWeb': 'Играть в браузере',
'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK',
'landing.captionWeb': 'Веб-версия',
'install.title': 'Установить приложение',
'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.',
'install.iosTitle': 'На экран «Домой»',
'install.iosStep1': 'Нажмите кнопку «Поделиться» на панели браузера.',
'install.iosStep2': 'Выберите «На экран „Домой“».',
'install.iosStep3': 'Нажмите «Добавить».',
'lang.en': 'English',
'lang.ru': 'Русский',
+88
View File
@@ -0,0 +1,88 @@
// Reactive PWA install runtime: captures the Chromium install prompt, tracks the installed
// state, and registers the install-only service worker. The pure platform branching lives in
// pwa.ts. Structured after the other reactive lib modules (app.svelte.ts, router.svelte.ts).
import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa';
import { insideTelegram } from './telegram';
import { insideVK } from './vk';
/** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web
* install makes no sense (the platform manages its own launcher). Kept here, out of the pure
* pwa.ts, so that module stays free of app imports for the node unit tests. */
function inMiniApp(): boolean {
return insideTelegram() || insideVK();
}
// beforeinstallprompt is a Chromium-only event, absent from the DOM lib types.
interface BeforeInstallPromptEvent extends Event {
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
prompt(): Promise<void>;
}
declare global {
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
appinstalled: Event;
}
}
// Not named `state` (a Svelte-check hazard: `$state` would then read as a store subscription).
const install = $state<{ deferred: BeforeInstallPromptEvent | null; installed: boolean }>({
deferred: null,
installed: isStandalone(),
});
/**
* installMode is the reactive install-CTA mode for the current platform and any captured prompt.
* Read inside a component's markup / $derived so the CTA updates when a Chromium prompt arrives
* (beforeinstallprompt fires shortly after load) or the app is installed.
*/
export function installMode(): InstallMode {
return resolveInstallMode({
deferredAvailable: install.deferred !== null,
standalone: install.installed,
iosSafari: isIosSafari(),
inMiniApp: inMiniApp(),
});
}
/**
* initInstallListeners wires the Chromium install prompt and the installed event. Harmless on
* any platform — the events fire only in an installable browser — so it may run unconditionally
* at startup, early enough to catch a prompt that fires before the CTA mounts.
*/
export function initInstallListeners(): void {
if (typeof window === 'undefined') return;
window.addEventListener('beforeinstallprompt', (e) => {
// Suppress Chromium's mini-infobar; the app presents its own CTA instead.
e.preventDefault();
install.deferred = e;
});
window.addEventListener('appinstalled', () => {
install.deferred = null;
install.installed = true;
});
}
/**
* promptInstall shows the native Chromium install dialog. It MUST be called directly from a user
* gesture (the deferred prompt is gesture-gated — no await before it). A deferred prompt is
* single-use, so it is cleared here; a no-op when nothing was captured.
*/
export async function promptInstall(): Promise<void> {
const deferred = install.deferred;
if (!deferred) return;
install.deferred = null;
await deferred.prompt();
}
/**
* registerServiceWorker registers the install-only worker (ui/public/sw.js). Web-only: skipped in
* the mock build (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini
* App. Best-effort — a failed registration only means the app is not installable.
*/
export function registerServiceWorker(): void {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
if (import.meta.env.MODE === 'mock') return;
if (inMiniApp()) return;
void navigator.serviceWorker.register('sw.js').catch(() => {});
}
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { isIosSafari, isStandalone, resolveInstallMode } from './pwa';
describe('resolveInstallMode', () => {
const base = { deferredAvailable: false, standalone: false, iosSafari: false, inMiniApp: false };
it('hides inside a Mini App even with a captured prompt', () => {
expect(resolveInstallMode({ ...base, inMiniApp: true, deferredAvailable: true })).toBe('hidden');
});
it('hides once the app is installed (standalone)', () => {
expect(resolveInstallMode({ ...base, standalone: true, deferredAvailable: true })).toBe('hidden');
});
it('offers one-tap when a Chromium prompt was captured', () => {
expect(resolveInstallMode({ ...base, deferredAvailable: true })).toBe('oneTap');
});
it('offers iOS instructions on iOS Safari without a prompt', () => {
expect(resolveInstallMode({ ...base, iosSafari: true })).toBe('iosInstructions');
});
it('prefers one-tap over iOS instructions when both apply', () => {
expect(resolveInstallMode({ ...base, deferredAvailable: true, iosSafari: true })).toBe('oneTap');
});
it('hides on an unsupported browser (no prompt, not iOS)', () => {
expect(resolveInstallMode(base)).toBe('hidden');
});
});
describe('isStandalone', () => {
afterEach(() => vi.unstubAllGlobals());
it('is true when the display-mode media query matches', () => {
vi.stubGlobal('window', {
matchMedia: (q: string) => ({ matches: q.includes('standalone') }),
navigator: {},
});
expect(isStandalone()).toBe(true);
});
it('is true for the iOS navigator.standalone flag', () => {
vi.stubGlobal('window', {
matchMedia: () => ({ matches: false }),
navigator: { standalone: true },
});
expect(isStandalone()).toBe(true);
});
it('is false in an ordinary browser tab', () => {
vi.stubGlobal('window', {
matchMedia: () => ({ matches: false }),
navigator: { standalone: false },
});
expect(isStandalone()).toBe(false);
});
});
describe('isIosSafari', () => {
afterEach(() => vi.unstubAllGlobals());
const IPHONE_SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const IPHONE_CHROME =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0 Mobile/15E148 Safari/604.1';
const ANDROID_CHROME =
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36';
const IPAD_SAFARI =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
it('is true for Safari on iPhone', () => {
vi.stubGlobal('navigator', { userAgent: IPHONE_SAFARI, maxTouchPoints: 5 });
expect(isIosSafari()).toBe(true);
});
it('is true for Safari on iPadOS (Mac UA + touch points)', () => {
vi.stubGlobal('navigator', { userAgent: IPAD_SAFARI, maxTouchPoints: 5 });
expect(isIosSafari()).toBe(true);
});
it('is false for a desktop Mac (no touch points)', () => {
vi.stubGlobal('navigator', { userAgent: IPAD_SAFARI, maxTouchPoints: 0 });
expect(isIosSafari()).toBe(false);
});
it('is false for Chrome on iOS (CriOS)', () => {
vi.stubGlobal('navigator', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 });
expect(isIosSafari()).toBe(false);
});
it('is false for Android Chrome', () => {
vi.stubGlobal('navigator', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 });
expect(isIosSafari()).toBe(false);
});
});
+57
View File
@@ -0,0 +1,57 @@
// Pure PWA install helpers, kept out of the Svelte layer (and free of app imports) so the
// platform branching is unit-testable in the node test env. The reactive runtime (the captured
// install prompt, the Mini-App gate, the service-worker registration) lives in pwa.svelte.ts.
/** InstallMode is how the install call-to-action resolves for the current platform. */
export type InstallMode = 'oneTap' | 'iosInstructions' | 'hidden';
/**
* isStandalone reports whether the app is already running as an installed PWA — launched from
* the home screen / desktop rather than a browser tab. Both the standard display-mode media
* query and the iOS-only navigator.standalone flag are checked. Returns false with no window
* (e.g. under the unit tests).
*/
export function isStandalone(): boolean {
if (typeof window === 'undefined') return false;
const displayMode = window.matchMedia?.('(display-mode: standalone)').matches ?? false;
const iosStandalone =
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
return displayMode || iosStandalone;
}
/**
* isIosSafari reports whether the app runs in Safari on iOS / iPadOS — the only browser there
* that can add a site to the home screen, and one with no programmatic install (it never fires
* beforeinstallprompt). In-app webviews (Telegram, VK) and the iOS Chrome/Firefox/Edge wrappers
* are excluded, as is desktop Safari. Used to switch the CTA to manual Share -> Add-to-Home-Screen
* instructions.
*/
export function isIosSafari(): boolean {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent;
// iPadOS 13+ reports a Mac UA but exposes touch points — catch that as iOS too.
const isIos =
/iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1);
if (!isIos) return false;
// Safari only: the iOS Chrome (CriOS), Firefox (FxiOS), Edge (EdgiOS), Opera (OPiOS) and the
// Google-app (GSA) wrappers all carry "Safari" but cannot add to the home screen from our CTA.
return /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS|GSA/.test(ua);
}
/**
* resolveInstallMode chooses how the install CTA should behave from the platform facts:
* `deferredAvailable` — a beforeinstallprompt event was captured (Chromium one-tap); `standalone`
* — already installed; `iosSafari` — manual instructions apply; `inMiniApp` — running inside a
* Telegram/VK Mini App. The CTA is offered only on the plain web and never once installed.
*/
export function resolveInstallMode(facts: {
deferredAvailable: boolean;
standalone: boolean;
iosSafari: boolean;
inMiniApp: boolean;
}): InstallMode {
if (facts.inMiniApp || facts.standalone) return 'hidden';
if (facts.deferredAvailable) return 'oneTap';
if (facts.iosSafari) return 'iosInstructions';
return 'hidden';
}
+5
View File
@@ -1,10 +1,15 @@
import { mount } from 'svelte';
import './app.css';
import App from './App.svelte';
import { initInstallListeners } from './lib/pwa.svelte';
// Pin the document for the game SPA (see app.css `html.app-shell`) so iOS/WKWebView — notably
// the Telegram Mini App — cannot rubber-band the whole page on a vertical drag. The standalone
// landing page (landing.ts) is a normal scrolling document and deliberately omits this class.
document.documentElement.classList.add('app-shell');
// Capture the PWA install prompt as early as possible — before the install CTA mounts. Harmless
// where it never fires (Firefox, Safari, the Telegram/VK Mini App webviews).
initInstallListeners();
export default mount(App, { target: document.getElementById('app')! });
+11
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { loginEmail, loginGuest, requestEmailCode } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import InstallApp from '../components/InstallApp.svelte';
let email = $state('');
let code = $state('');
@@ -55,6 +56,9 @@
</button>
{/if}
</div>
<!-- Web-only install call-to-action, under the login card. Renders nothing unless the app is
installable here (see components/InstallApp.svelte / lib/pwa). -->
<div class="promo"><InstallApp /></div>
</main>
<style>
@@ -62,8 +66,15 @@
height: 100%;
display: grid;
place-items: center;
/* Centre the card + install promo as a group, with a gap between them. */
align-content: center;
gap: 16px;
padding: 16px;
}
/* Match the card width so the install CTA lines up under it. */
.promo {
width: min(94vw, 360px);
}
.card {
width: min(94vw, 360px);
background: var(--surface);
+5
View File
@@ -10,6 +10,7 @@
import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels';
import { insideTelegram } from '../lib/telegram';
import InstallApp from '../components/InstallApp.svelte';
const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = {
@@ -73,6 +74,10 @@
/>
</label>
</section>
<!-- Web-only install call-to-action at the bottom of Settings (renders nothing unless the app
is installable here — see components/InstallApp.svelte / lib/pwa). -->
<InstallApp />
</div>
<style>