From 51147a1429838ae5bf3ad2704a4b03ca1c8a91b1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 5 Jul 2026 21:05:52 +0200 Subject: [PATCH 1/3] feat(pwa): installable web app + landing web entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/ARCHITECTURE.md | 9 ++- docs/FUNCTIONAL.md | 8 ++- docs/FUNCTIONAL_ru.md | 8 ++- gateway/README.md | 3 + gateway/internal/webui/webui.go | 25 +++++-- gateway/internal/webui/webui_test.go | 54 +++++++++++++++ ui/e2e/install.spec.ts | 59 ++++++++++++++++ ui/e2e/landing.spec.ts | 12 ++++ ui/index.html | 12 ++++ ui/public/icon-192.png | Bin 0 -> 2994 bytes ui/public/icon-512.png | Bin 0 -> 7668 bytes ui/public/icon-maskable-512.png | Bin 0 -> 5745 bytes ui/public/manifest.webmanifest | 17 +++++ ui/public/sw.js | 53 +++++++++++++++ ui/scripts/bundle-size.mjs | 9 +-- ui/src/Landing.svelte | 47 ++++++++----- ui/src/components/InstallApp.svelte | 85 ++++++++++++++++++++++++ ui/src/lib/app.svelte.ts | 6 ++ ui/src/lib/i18n/en.ts | 11 +++ ui/src/lib/i18n/ru.ts | 11 +++ ui/src/lib/pwa.svelte.ts | 88 ++++++++++++++++++++++++ ui/src/lib/pwa.test.ts | 96 +++++++++++++++++++++++++++ ui/src/lib/pwa.ts | 57 ++++++++++++++++ ui/src/main.ts | 5 ++ ui/src/screens/Login.svelte | 11 +++ ui/src/screens/Settings.svelte | 5 ++ 26 files changed, 662 insertions(+), 29 deletions(-) create mode 100644 ui/e2e/install.spec.ts create mode 100644 ui/public/icon-192.png create mode 100644 ui/public/icon-512.png create mode 100644 ui/public/icon-maskable-512.png create mode 100644 ui/public/manifest.webmanifest create mode 100644 ui/public/sw.js create mode 100644 ui/src/components/InstallApp.svelte create mode 100644 ui/src/lib/pwa.svelte.ts create mode 100644 ui/src/lib/pwa.test.ts create mode 100644 ui/src/lib/pwa.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 99e47b1..eeeb5ea 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 806e3a8..1d96e2e 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 333ff76..cd849b4 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -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-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое diff --git a/gateway/README.md b/gateway/README.md index 426c2fc..a7adc18 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -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 diff --git a/gateway/internal/webui/webui.go b/gateway/internal/webui/webui.go index 0192096..d999377 100644 --- a/gateway/internal/webui/webui.go +++ b/gateway/internal/webui/webui.go @@ -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), "/") diff --git a/gateway/internal/webui/webui_test.go b/gateway/internal/webui/webui_test.go index c40d30f..90c174e 100644 --- a/gateway/internal/webui/webui_test.go +++ b/gateway/internal/webui/webui_test.go @@ -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("shell")}, + "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) + } +} diff --git a/ui/e2e/install.spec.ts b/ui/e2e/install.spec.ts new file mode 100644 index 0000000..4188bb7 --- /dev/null +++ b/ui/e2e/install.spec.ts @@ -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; + userChoice?: Promise; + }; + (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(); + }); +}); diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index e6a5ab6..ebb3fe4 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -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(); +}); diff --git a/ui/index.html b/ui/index.html index 5095a37..bf38301 100644 --- a/ui/index.html +++ b/ui/index.html @@ -256,6 +256,18 @@ + + + + + + + + +
diff --git a/ui/public/icon-192.png b/ui/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b87e1700a77115957a244e5f9d0818795e5ba036 GIT binary patch literal 2994 zcmZWrc{tSF7r);zn5<(dvQ9#iLZz%z24ibMQN}I`g%_`-tTVQ3S>DJJ*@9 zLom_%8zWB4bU5UyW8w|~EB8TzdPW0w=_KTSLt7n`bO_DTCvbOlT_Xft{1A7->8rGL zacV{n(-ucx>kFEQFfWfcXhjv_42Yyt7Va;q6q{+BFm{JLl;IOe$;EVZ0b1j-6pP0< zrk)UPTyAf71C2R-h9zgzF3;kVjE%$t<&=1CztBBve#{RAxq1aT%HF#S$wT6{%mL$@ z0hIQNV=%f4|BA7P!#wk^gb&CTWAJ7y>*Ccw7OhQ;ATEDFXs-f>v+a25Y0V$nns zObok#J6xymv+mqF|AbSoBnRCP=C{7X{!Pn^4 zY*pCSWkuUP!K+$UWMnt9L-}QEHhVtxYy=GXX6}|gvLRRZm`;sc?kKF8d3xU~`@)c&@Tx&3A&wL?iQK`bToSaoYAwSPEy}Q_m|N zHo)TrH!=I_5Le_=A9msf^JhK#<&Iq+K+M)GVK{%416Y1zt?Vfn?UMsX8Vrt@+~<-; zhCc#C2L$*7Obm5Dhx3#(xeA#1gV*P8@6< zh~@851L{ubT{k2Cxqil1!rWM(#i8#v9~qFg{Hp9tojKJ|>cR@-nz!6SS0GVWrJY~( z7Yr91-t)Mu>jX>}IfT>wtVf$q76sd8m@di;AZ)|gh+lF75+(#7=Wb~??PKCc2yyqL z#M}Ioo8Vag9};|ou7RcD_L!tQVy39soB-bS3;(>udId0HS6WmQH#9rpdd!*qOPO02 zgoJiTWZzrVUUWzWL_d7qZaxR4FVW>x`70~g-ZTpa-u3pk7bDA%Tlif;1aqu@Z-MhE zn#|lJk(mT70jqw*p*XyR&;x<4BR}k%GR{ZlqG9-d%+x3dUgJNG7%HQS6~fi z_(z*xe33)xFcA}()9QX+117r9Sm5~gQ+xT}>}@uFaMPPjPkRwCG#qC<7-N*=J zl{_n6kT~J%W_NRd=b2EI#>Cgt*3RHrw7~Ov@5b`^tTC6~nWV6=iQvL^r0WiFKGXgv#qQv4J;=_E1OhOg0L@+@inAkkk-F>^-Zu z6frn9^Q-MUFhrBArBi9F#@_}Z1koVhQvIGF9E&cINAH$+RjPrI$@{@&Okl-{1A?LW z2TxnCsq;ffwoL{kK|DaI+vMnIIToBN+dF)dCee9RB(JOFN6mVPZhWmzZducNY@=L7*{xaitdIxySXEo=7nIkcAAhJ@`_VJl_SxAW zX7WY%Y1D1A1YhcdyuD*)3=PAzqCz`*lqk{kW!k>E$*$BbCStgQZL}=htk&LXP>N?M zR8~oTJH8#77;AKsh8KIawC;b(>&|fE0B1X4^l9NbF=CCDW<@D4QwHr?(d(&`pR*r2 zZa((6Ff5f(^d4`T195@3S~rl%=zzb=HjQ6#f}_dLIAi>6L+@NJ8qLZ}a*4*+4|)Xz zr6GtBg(lKs&WOhE5iTBFl(`xG5;&1fri-4$mq)qXMtL8XzrN+}MLm%ks~%El(ttb5 z=91`HIQ;O&GaG}f^Cr6`GJe5nhd{JC{c%K-q~|d0W#~v&b&=50s*!E99wYs5F22JK z6iXxI>S;gju5EM&BT?%Z-sktgj4eFaptM6!$@KYy?o)z}Ui%}#;%RJvt9xvN#d(p| zyY(X^R-h;)kXuC1U~pNs_TyJY;U|$9<{a2MWqmR6fG)>uJ!R$qZWvYOM%NNM_Rl;6cCbvx<{( zXxa|014JeyB0@#4d4L2vTRf_S5eij33PWZF(W5auu75%w?dOkZ;D33c{3K$|PLxIU zC>*7wh%hQY&ORqDm9=cB0nQho(3*^s8#C)c>#FIb(?}xkB+PB+HMJ4(S9|tEyE@O2r-xK^qB#8TOM*d$O9;E3X@&Scm zBsh7uu=x|TJR`Q=4@Kr)uT6%qqzH)LPk1-2(-6{`wxz5M#m@KrHFp#>rv_aX_hlv3 zc$0Y6l}Ks^q=5a;%VNpdd^`xiD_zdBGGpVp7H-ca)PE(5JxHuMnigCdJg7X_#ey{# z?3?%RsfGiU7zppng{czBD2T1=L_kwAm79Z3gz$MZ-1JzhnR)wcrD}pWC`#2>b zoNbae7&+ZVvAX6L1nyCNGG>yj2vNnwjqBDYXFXrU(}ExDZw)rDiPGl0)88mAty#VE z%OdPg*Y{EOV?{7qk3=vHshw)_bz?6g=G(5%yT?(#Mwp>~dJz0uQdcdnZTmj`%+}t1 zDfjYS$wiGo?ENHd$Kd9iD0|g+S1{X#i2%t}w?F$^iQQr0Dl#8-Mqy`ptOYCIKTu#5 zzaSN(MQhzLioe4GB$5w-s=LwwlL)}BQFc&!2@S%!8n5p*9R`dQQ1Zu{TP%)1r=6*H z?9?b3BF1!Y)NXJwTP#2ZdYglM5#dNCo%Wol#=*wUwBxkBu+~5p1}B__ l7f!fN!G!v66-!6dhkeL3YAfJ(&ZVdWT31sKSE6nm^dD01X7>O9 literal 0 HcmV?d00001 diff --git a/ui/public/icon-512.png b/ui/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..0e1aee5c0fade58a3149db99954d95c251c1758e GIT binary patch literal 7668 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 literal 0 HcmV?d00001 diff --git a/ui/public/icon-maskable-512.png b/ui/public/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..a94d40c7d3fd5baecbf13a498fee173b960eb32c GIT binary patch literal 5745 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 Q8)C4_*4d_fhyU6C0RBa*00000 literal 0 HcmV?d00001 diff --git a/ui/public/manifest.webmanifest b/ui/public/manifest.webmanifest new file mode 100644 index 0000000..b1f7ecc --- /dev/null +++ b/ui/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/ui/public/sw.js b/ui/public/sw.js new file mode 100644 index 0000000..af2d52f --- /dev/null +++ b/ui/public/sw.js @@ -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(); + } + })(), + ); +}); diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 61c0519..0403740 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -19,10 +19,11 @@ import { join } from 'node:path'; const DIST = 'dist'; -// Per-chunk gzip budgets in KB. The app entry was raised to 110 when the local -// move-preview wiring landed (the heavy dict subsystem stays in lazy chunks; this -// covers the small in-entry game/debug wiring it needs). -const BUDGET = { app: 110, shared: 30, landing: 5 }; +// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview +// wiring, then to 112 for the PWA install feature (the install CTA + the pwa detection / +// service-worker registration live in the app entry; the heavy dict subsystem stays in lazy +// chunks). Its scoped CSS lands in the CSS chunk, not this JS budget. +const BUDGET = { app: 112, shared: 30, landing: 5 }; // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // local file (e.g. the Telegram SDK loaded from a CDN) or is missing. diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index 3a8d587..9f4bf3b 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -80,20 +80,26 @@

{about.title}

{t('landing.tagline')}

- {#if tgLink || vkLink} -
- {#if tgLink} - - - - {/if} - {#if vkLink} - - - - {/if} -
- {/if} + +
@@ -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); diff --git a/ui/src/components/InstallApp.svelte b/ui/src/components/InstallApp.svelte new file mode 100644 index 0000000..d0c4933 --- /dev/null +++ b/ui/src/components/InstallApp.svelte @@ -0,0 +1,85 @@ + + +{#if mode !== 'hidden'} + +{/if} + +{#if showIos} + (showIos = false)}> +
    +
  1. {t('install.iosStep1')}
  2. +
  3. {t('install.iosStep2')}
  4. +
  5. {t('install.iosStep3')}
  6. +
+ +
+{/if} + + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index c92fd44..25bbc16 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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 { 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). diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index e19daca..a104b9a 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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': 'Русский', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 81e6966..b95fb2e 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -234,6 +234,17 @@ export const ru: Record = { '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': 'Русский', diff --git a/ui/src/lib/pwa.svelte.ts b/ui/src/lib/pwa.svelte.ts new file mode 100644 index 0000000..8514093 --- /dev/null +++ b/ui/src/lib/pwa.svelte.ts @@ -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; +} +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 { + 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(() => {}); +} diff --git a/ui/src/lib/pwa.test.ts b/ui/src/lib/pwa.test.ts new file mode 100644 index 0000000..1324e5a --- /dev/null +++ b/ui/src/lib/pwa.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/pwa.ts b/ui/src/lib/pwa.ts new file mode 100644 index 0000000..65a69fb --- /dev/null +++ b/ui/src/lib/pwa.ts @@ -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'; +} diff --git a/ui/src/main.ts b/ui/src/main.ts index af36848..a3cfdce 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -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')! }); diff --git a/ui/src/screens/Login.svelte b/ui/src/screens/Login.svelte index 3e71227..224ec9f 100644 --- a/ui/src/screens/Login.svelte +++ b/ui/src/screens/Login.svelte @@ -1,6 +1,7 @@